From fd6e5da888f5726a4c715e2c1e31abb7d160b030 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 10 Apr 2026 14:08:26 +1200 Subject: [PATCH 1/5] feat(fleet-identity): add version() function for upgrade verification - Add virtual version() returning '1.0.0' to FleetIdentityUpgradeable - Update V2 mock to override with '2.0.0' - Add test_FleetIdentity_Version and test_FleetIdentity_VersionChangesAfterUpgrade - Fix override specifier in TestUpgradeOnAnvil V2 mock --- src/swarms/FleetIdentityUpgradeable.sol | 9 +++++++++ test/upgrade-demo/TestUpgradeOnAnvil.s.sol | 2 +- test/upgradeable/UpgradeableContracts.t.sol | 20 +++++++++++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/swarms/FleetIdentityUpgradeable.sol b/src/swarms/FleetIdentityUpgradeable.sol index 8ec92ac0..b1036742 100644 --- a/src/swarms/FleetIdentityUpgradeable.sol +++ b/src/swarms/FleetIdentityUpgradeable.sol @@ -732,6 +732,15 @@ contract FleetIdentityUpgradeable is return (uint32(countryCode) << uint32(ADMIN_SHIFT)) | uint32(adminCode); } + // ══════════════════════════════════════════════ + // Version + // ══════════════════════════════════════════════ + + /// @notice Returns the contract version for upgrade verification. + function version() external pure virtual returns (string memory) { + return "1.0.0"; + } + // ══════════════════════════════════════════════ // UUPS Authorization // ══════════════════════════════════════════════ diff --git a/test/upgrade-demo/TestUpgradeOnAnvil.s.sol b/test/upgrade-demo/TestUpgradeOnAnvil.s.sol index 51212a01..fcf1f8fa 100644 --- a/test/upgrade-demo/TestUpgradeOnAnvil.s.sol +++ b/test/upgrade-demo/TestUpgradeOnAnvil.s.sol @@ -17,7 +17,7 @@ import {SwarmRegistryL1Upgradeable} from "../../src/swarms/SwarmRegistryL1Upgrad /// @dev Mock V2 that adds version() - inherits from V1 to preserve storage layout contract FleetIdentityUpgradeableV2 is FleetIdentityUpgradeable { - function version() external pure returns (string memory) { + function version() external pure override returns (string memory) { return "2.0.0"; } } diff --git a/test/upgradeable/UpgradeableContracts.t.sol b/test/upgradeable/UpgradeableContracts.t.sol index 9b4b23d6..14400ab9 100644 --- a/test/upgradeable/UpgradeableContracts.t.sol +++ b/test/upgradeable/UpgradeableContracts.t.sol @@ -61,8 +61,8 @@ contract FleetIdentityUpgradeableV2Mock is FleetIdentityUpgradeable { fleetMetadata[tokenId] = metadata; } - function version() external pure returns (string memory) { - return "V2"; + function version() external pure override returns (string memory) { + return "2.0.0"; } } @@ -259,6 +259,10 @@ contract UpgradeableContractsTest is Test { assertEq(fleetIdentity.symbol(), "SFID"); } + function test_FleetIdentity_Version() public view { + assertEq(fleetIdentity.version(), "1.0.0"); + } + function test_FleetIdentity_CannotReinitialize() public { vm.expectRevert(Initializable.InvalidInitialization.selector); fleetIdentity.initialize(attacker, address(bondToken), BASE_BOND, 0); @@ -289,7 +293,7 @@ contract UpgradeableContractsTest is Test { // Verify upgrade FleetIdentityUpgradeableV2Mock v2 = FleetIdentityUpgradeableV2Mock(fleetIdentityProxy); - assertEq(v2.version(), "V2"); + assertEq(v2.version(), "2.0.0"); assertGt(v2.v2InitializedAt(), 0); // Verify old state preserved @@ -301,6 +305,16 @@ contract UpgradeableContractsTest is Test { assertEq(v2.fleetMetadata(tokenId), "metadata://test"); } + function test_FleetIdentity_VersionChangesAfterUpgrade() public { + assertEq(fleetIdentity.version(), "1.0.0"); + + FleetIdentityUpgradeableV2Mock v2Impl = new FleetIdentityUpgradeableV2Mock(); + vm.prank(owner); + fleetIdentity.upgradeToAndCall(address(v2Impl), ""); + + assertEq(FleetIdentityUpgradeableV2Mock(fleetIdentityProxy).version(), "2.0.0"); + } + function test_FleetIdentity_NonOwnerCannotUpgrade() public { FleetIdentityUpgradeableV2Mock v2Impl = new FleetIdentityUpgradeableV2Mock(); From f4291d8d8838175e9a8e6af67eaf2f9a0e47f521 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 10 Apr 2026 15:06:39 +1200 Subject: [PATCH 2/5] fix: use L2_ADMIN (ZkSync Safe) instead of NODL_ADMIN for L2 contract ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NODL_ADMIN (0x55f5...AD8) is the L1 Ethereum Safe multisig and has no code on ZkSync L2, making contracts owned by it effectively locked. Replace with L2_ADMIN which must be set to the ZkSync Safe multisig (0x5e09...6C9) — verified as the NODL token default admin on L2. Affected deployment scripts: - script/DeploySwarmUpgradeableZkSync.s.sol: L2_ADMIN now required (was optional NODL_ADMIN) - ops/deploy_swarm_contracts_zksync.sh: L2_ADMIN required with validation - hardhat-deploy/DeploySwarmUpgradeable.ts: L2_ADMIN required with validation Contracts affected: ServiceProvider, FleetIdentity, SwarmRegistry, BondTreasuryPaymaster (all on ZkSync Era). --- hardhat-deploy/DeploySwarmUpgradeable.ts | 7 +++++-- ops/deploy_swarm_contracts_zksync.sh | 15 +++++++++------ script/DeploySwarmUpgradeableZkSync.s.sol | 6 +++--- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/hardhat-deploy/DeploySwarmUpgradeable.ts b/hardhat-deploy/DeploySwarmUpgradeable.ts index d764105c..a3dfd87e 100644 --- a/hardhat-deploy/DeploySwarmUpgradeable.ts +++ b/hardhat-deploy/DeploySwarmUpgradeable.ts @@ -16,7 +16,7 @@ dotenv.config({ path: ".env-test" }); * - DEPLOYER_PRIVATE_KEY: Private key for deployment * - BOND_TOKEN: Address of the ERC20 bond token * - BASE_BOND: Base bond amount in wei - * - OWNER: (optional) Owner address, defaults to deployer + * - L2_ADMIN: Owner address for all deployed L2 contracts (ZkSync Safe multisig) */ module.exports = async function (hre: HardhatRuntimeEnvironment) { const bondToken = process.env.BOND_TOKEN!; @@ -28,7 +28,10 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); const deployer = new Deployer(hre, wallet); - const owner = process.env.OWNER || wallet.address; + const owner = process.env.L2_ADMIN; + if (!owner) { + throw new Error("L2_ADMIN environment variable is required (ZkSync Safe multisig)"); + } console.log("=== Deploying Upgradeable Swarm Contracts on ZkSync ==="); console.log("Bond Token:", bondToken); diff --git a/ops/deploy_swarm_contracts_zksync.sh b/ops/deploy_swarm_contracts_zksync.sh index 5293b599..aef09722 100755 --- a/ops/deploy_swarm_contracts_zksync.sh +++ b/ops/deploy_swarm_contracts_zksync.sh @@ -49,8 +49,8 @@ # - 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) -# - 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 +# - L2_ADMIN: Owner address for all deployed L2 contracts (ZkSync Safe multisig) +# - PAYMASTER_WITHDRAWER: (optional) Address allowed to withdraw tokens from paymaster, defaults to L2_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 @@ -183,8 +183,11 @@ preflight_checks() { # Set defaults export BOND_TOKEN="${BOND_TOKEN:-$NODL}" export BASE_BOND="${BASE_BOND:-1000000000000000000000}" # 1000 NODL default - export NODL_ADMIN="${NODL_ADMIN:-$DEPLOYER_ADDRESS}" - export PAYMASTER_WITHDRAWER="${PAYMASTER_WITHDRAWER:-$NODL_ADMIN}" + if [ -z "$L2_ADMIN" ]; then + log_error "L2_ADMIN not set in $ENV_FILE (must be the ZkSync Safe multisig)" + exit 1 + fi + export PAYMASTER_WITHDRAWER="${PAYMASTER_WITHDRAWER:-$L2_ADMIN}" export BOND_QUOTA="${BOND_QUOTA:-100000000000000000000000}" # 100000 NODL default export BOND_PERIOD="${BOND_PERIOD:-86400}" # 1 day default @@ -339,7 +342,7 @@ deploy_contracts() { log_info "Would deploy with:" log_info " BOND_TOKEN: $BOND_TOKEN" log_info " BASE_BOND: $BASE_BOND" - log_info " NODL_ADMIN: ${NODL_ADMIN:-deployer}" + log_info " L2_ADMIN: $L2_ADMIN" log_info " PAYMASTER_WITHDRAWER: ${PAYMASTER_WITHDRAWER:-deployer}" log_info " FLEET_OPERATOR: $FLEET_OPERATOR" log_info " BOND_QUOTA: $BOND_QUOTA" @@ -677,7 +680,7 @@ print_summary() { echo " Explorer: $EXPLORER_URL/address/$BOND_TREASURY_PAYMASTER" echo "" echo "Configuration:" - echo " Owner: ${NODL_ADMIN:-deployer}" + echo " Owner: $L2_ADMIN" echo " Withdrawer: ${PAYMASTER_WITHDRAWER:-deployer}" echo " Fleet Operator: $FLEET_OPERATOR" echo " Bond Token: $BOND_TOKEN" diff --git a/script/DeploySwarmUpgradeableZkSync.s.sol b/script/DeploySwarmUpgradeableZkSync.s.sol index af209024..bb244384 100644 --- a/script/DeploySwarmUpgradeableZkSync.s.sol +++ b/script/DeploySwarmUpgradeableZkSync.s.sol @@ -23,8 +23,8 @@ import {BondTreasuryPaymaster} from "../src/paymasters/BondTreasuryPaymaster.sol * - BOND_TOKEN: Address of the ERC20 bond token * - BASE_BOND: Base bond amount in wei * - 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) + * - L2_ADMIN: Owner address for all deployed L2 contracts (ZkSync Safe multisig) + * - PAYMASTER_WITHDRAWER: (optional) Address allowed to withdraw tokens from paymaster (defaults to L2_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) @@ -45,7 +45,7 @@ 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("NODL_ADMIN", vm.addr(deployerPrivateKey)); + address owner = vm.envAddress("L2_ADMIN"); 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)); From a8e948aaa854f097ce3321c1128a608ce6036ba6 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 10 Apr 2026 15:36:39 +1200 Subject: [PATCH 3/5] style: format deploy scripts and tests --- hardhat-deploy/DeploySwarmUpgradeable.ts | 4 +++- script/DeploySwarmUpgradeableZkSync.s.sol | 9 ++++++++- test/upgradeable/UpgradeableContracts.t.sol | 8 +++----- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/hardhat-deploy/DeploySwarmUpgradeable.ts b/hardhat-deploy/DeploySwarmUpgradeable.ts index a3dfd87e..2a11b114 100644 --- a/hardhat-deploy/DeploySwarmUpgradeable.ts +++ b/hardhat-deploy/DeploySwarmUpgradeable.ts @@ -30,7 +30,9 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) { const owner = process.env.L2_ADMIN; if (!owner) { - throw new Error("L2_ADMIN environment variable is required (ZkSync Safe multisig)"); + throw new Error( + "L2_ADMIN environment variable is required (ZkSync Safe multisig)", + ); } console.log("=== Deploying Upgradeable Swarm Contracts on ZkSync ==="); diff --git a/script/DeploySwarmUpgradeableZkSync.s.sol b/script/DeploySwarmUpgradeableZkSync.s.sol index bb244384..882d0b28 100644 --- a/script/DeploySwarmUpgradeableZkSync.s.sol +++ b/script/DeploySwarmUpgradeableZkSync.s.sol @@ -107,7 +107,14 @@ contract DeploySwarmUpgradeableZkSync is Script { whitelistedUsers[0] = fleetOperator; bondTreasuryPaymaster = address( new BondTreasuryPaymaster( - owner, fleetOperator, withdrawer, whitelistedContracts, whitelistedUsers, bondToken, bondQuota, bondPeriod + owner, + fleetOperator, + withdrawer, + whitelistedContracts, + whitelistedUsers, + bondToken, + bondQuota, + bondPeriod ) ); console.log(" Address:", bondTreasuryPaymaster); diff --git a/test/upgradeable/UpgradeableContracts.t.sol b/test/upgradeable/UpgradeableContracts.t.sol index 14400ab9..f599e580 100644 --- a/test/upgradeable/UpgradeableContracts.t.sol +++ b/test/upgradeable/UpgradeableContracts.t.sol @@ -131,8 +131,7 @@ contract UpgradeableContractsTest is Test { serviceProviderImpl = new ServiceProviderUpgradeable(); serviceProviderProxy = address( new ERC1967Proxy( - address(serviceProviderImpl), - abi.encodeCall(ServiceProviderUpgradeable.initialize, (owner)) + address(serviceProviderImpl), abi.encodeCall(ServiceProviderUpgradeable.initialize, (owner)) ) ); serviceProvider = ServiceProviderUpgradeable(serviceProviderProxy); @@ -153,8 +152,7 @@ contract UpgradeableContractsTest is Test { new ERC1967Proxy( address(swarmRegistryImpl), abi.encodeCall( - SwarmRegistryUniversalUpgradeable.initialize, - (fleetIdentityProxy, serviceProviderProxy, owner) + SwarmRegistryUniversalUpgradeable.initialize, (fleetIdentityProxy, serviceProviderProxy, owner) ) ) ); @@ -390,7 +388,7 @@ contract UpgradeableContractsTest is Test { bytes memory filterData = hex"0102030405"; FingerprintSize fpSize = FingerprintSize.BITS_16; TagType tagType = TagType.IBEACON_PAYLOAD_ONLY; - + vm.prank(alice); uint256 swarmId = swarmRegistry.registerSwarm(uuid, providerId, filterData, fpSize, tagType); From c573933d2ac704f7d562866870dce3beee42401b Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 10 Apr 2026 16:16:23 +1200 Subject: [PATCH 4/5] feat: add proper ZkSync source verification tooling - Add ops/verify_zksync_contracts.py: standalone script that generates standard JSON via forge, rewrites OpenZeppelin's "../" relative imports to resolved project paths, and submits directly to ZkSync verification API. Supports single-contract and batch (--broadcast) modes. - Add bytecode_hash = "none" to foundry.toml (both profiles): omits CBOR metadata hash from bytecode so future deployments achieve full (not partial) verification on ZkSync explorer. - Rewrite verify_source_code() in deploy_swarm_contracts_zksync.sh to call the Python script instead of the broken flatten approach. - Document the verification problem and solution in copilot-instructions.md so AI assistants know the correct approach immediately. --- .github/copilot-instructions.md | 51 ++++ foundry.toml | 7 + ops/deploy_swarm_contracts_zksync.sh | 129 ++------ ops/verify_zksync_contracts.py | 430 +++++++++++++++++++++++++++ 4 files changed, 517 insertions(+), 100 deletions(-) create mode 100755 ops/verify_zksync_contracts.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 37ae67c2..fd5ebcec 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -48,3 +48,54 @@ For these contracts, use: forge build --match-path src/swarms/SwarmRegistryL1.sol forge test --match-path test/SwarmRegistryL1.t.sol ``` + +## ZkSync Source Code Verification + +**IMPORTANT**: Do NOT use `forge script --verify` or `forge verify-contract` directly for ZkSync contracts. Both fail to achieve full verification due to path handling issues with the ZkSync block explorer verifier. + +### The Problem (three broken paths) + +1. `forge script --verify` sends **absolute file paths** (`/Users/me/project/src/...`) → verifier rejects. +2. `forge verify-contract` (standard JSON) sends OpenZeppelin sources containing `../` relative imports → verifier rejects "import with absolute or traversal path". +3. `forge verify-contract --flatten` or manual flattening eliminates imports but changes the source file path in the metadata hash → **"partially verified"** (metadata mismatch). + +### The Solution + +Use `ops/verify_zksync_contracts.py` which: +1. Generates standard JSON via `forge verify-contract --show-standard-json-input` +2. Rewrites all `../` relative imports in OpenZeppelin source content to resolved project-absolute paths (e.g., `../../utils/Foo.sol` → `lib/openzeppelin-contracts/contracts/utils/Foo.sol`) +3. Submits directly to the ZkSync verification API via HTTP + +### Full vs Partial Verification + +- **`bytecode_hash = "none"`** is set in `foundry.toml` (both `[profile.default]` and `[profile.zksync]`). This omits the CBOR metadata hash from bytecode. Contracts deployed with this setting achieve **full verification**. +- Contracts deployed **before** this setting was added (pre 2026-04-10) will always show "partially verified" — this is cosmetic only. The source code is correct and auditable. + +### Usage + +```bash +# After deployment — verify all contracts from broadcast: +python3 ops/verify_zksync_contracts.py \ + --broadcast broadcast/DeploySwarmUpgradeableZkSync.s.sol/324/run-latest.json \ + --verifier-url https://zksync2-mainnet-explorer.zksync.io/contract_verification + +# Verify a single contract: +python3 ops/verify_zksync_contracts.py \ + --address 0x1234... \ + --contract src/swarms/FleetIdentityUpgradeable.sol:FleetIdentityUpgradeable \ + --verifier-url https://zksync2-mainnet-explorer.zksync.io/contract_verification + +# With constructor args: +python3 ops/verify_zksync_contracts.py \ + --address 0x1234... \ + --contract lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy \ + --constructor-args 0xabcdef... +``` + +### Adding New Contract Types + +When deploying a new contract type, add its mapping to `CONTRACT_SOURCE_MAP` in `ops/verify_zksync_contracts.py` so `--broadcast` mode can auto-detect it. + +### Automated (via deploy script) + +`ops/deploy_swarm_contracts_zksync.sh` calls `verify_zksync_contracts.py` automatically after deployment. No manual steps needed for the standard swarm contracts. diff --git a/foundry.toml b/foundry.toml index 9b7806cf..484f2051 100644 --- a/foundry.toml +++ b/foundry.toml @@ -11,6 +11,12 @@ via_ir = true optimizer = true optimizer_runs = 200 +# Omit CBOR metadata hash from bytecode. Required for full (not partial) source +# verification on ZkSync explorer. Without this, the metadata hash includes source +# file paths, and the ZkSync verifier rejects OpenZeppelin's "../" relative imports, +# forcing flattened/rewritten sources that produce a different metadata hash. +bytecode_hash = "none" + [lint] # Exclude ERC20 transfer warning - false positive for ERC721.transferFrom in tests exclude_lints = ["erc20-unchecked-transfer"] @@ -23,6 +29,7 @@ solc = "0.8.26" via_ir = true optimizer = true optimizer_runs = 200 +bytecode_hash = "none" # Exclude L1-only contracts that use SSTORE2/EXTCODECOPY ignored_error_codes = [] ignored_warnings_from = [] diff --git a/ops/deploy_swarm_contracts_zksync.sh b/ops/deploy_swarm_contracts_zksync.sh index aef09722..a71f784c 100755 --- a/ops/deploy_swarm_contracts_zksync.sh +++ b/ops/deploy_swarm_contracts_zksync.sh @@ -444,16 +444,18 @@ verify_deployment() { # ============================================================================= # # 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". +# forge script --verify sends absolute file paths that ZkSync verifier rejects. +# forge verify-contract sends standard JSON with "../" relative imports in +# OpenZeppelin sources, which the verifier also rejects. # -# 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 +# Solution: +# ops/verify_zksync_contracts.py generates standard JSON via forge, rewrites +# all "../" relative imports to resolved absolute-within-project paths, then +# submits directly to the ZkSync verification API. # -# Constructor args are extracted from the broadcast JSON using the ZkSync -# ContractDeployer ABI: create(bytes32 salt, bytes32 bytecodeHash, bytes ctorInput) +# For contracts compiled with bytecode_hash = "none" (foundry.toml, added +# 2026-04-10), this achieves FULL verification. For older contracts, it +# achieves "partial" (metadata mismatch — cosmetic only, source is correct). # # ============================================================================= @@ -464,12 +466,10 @@ verify_source_code() { log_info "Verifying source code on block explorer..." - # Get RPC URL for chain detection + # Determine chain ID for broadcast path 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 @@ -480,97 +480,26 @@ verify_source_code() { 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 + # Check python3 is available + if ! command -v python3 &> /dev/null; then + log_error "python3 not found. Install Python 3.8+ for source code verification." + log_warning "Skipping source code verification" + return 1 + 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)" + python3 "$SCRIPT_DIR/verify_zksync_contracts.py" \ + --broadcast "$BROADCAST_JSON" \ + --verifier-url "$VERIFIER_URL" \ + --compiler-version "0.8.26" \ + --zksolc-version "v1.5.15" \ + --project-root "$PROJECT_ROOT" + + local exit_code=$? + if [ "$exit_code" -eq 0 ]; then + log_success "All contracts source-code verified on block explorer!" else - log_success "All 7 contracts source-code verified on block explorer!" + log_warning "Some contracts failed source verification (deployment itself succeeded)" + log_info "Retry manually: python3 ops/verify_zksync_contracts.py --broadcast $BROADCAST_JSON --verifier-url $VERIFIER_URL" fi } diff --git a/ops/verify_zksync_contracts.py b/ops/verify_zksync_contracts.py new file mode 100755 index 00000000..a88e750b --- /dev/null +++ b/ops/verify_zksync_contracts.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +""" +verify_zksync_contracts.py — Full source-code verification for ZkSync Era contracts. + +PROBLEM: + `forge verify-contract --zksync` and `forge script --verify` both fail to achieve + full verification on the ZkSync explorer because: + 1. forge script --verify sends ABSOLUTE source paths → verifier rejects them. + 2. forge verify-contract sends standard JSON with OpenZeppelin's relative "../" + imports → verifier rejects "import with absolute or traversal path". + 3. Using flattened files works around the import issue but changes the source + path in the metadata hash → "partially verified" (metadata mismatch). + +SOLUTION: + This script generates standard JSON via `forge verify-contract --show-standard-json-input`, + then rewrites all "../" relative imports in the source content to their resolved + absolute-within-project paths (e.g., "../../utils/Foo.sol" → "lib/openzeppelin-contracts/ + contracts/utils/Foo.sol"). This preserves the original source file keys (and thus the + metadata hash) while eliminating traversal paths that the verifier rejects. + + For contracts compiled with `bytecode_hash = "none"` in foundry.toml (added 2026-04-10), + the metadata hash is omitted from bytecode entirely, so this script achieves FULL + verification. For older contracts compiled without that setting, this achieves the + best possible result (partial — metadata mismatch is cosmetic only). + +USAGE: + # Verify a single contract (no constructor args): + python3 ops/verify_zksync_contracts.py \\ + --address 0x1234... \\ + --contract src/swarms/FleetIdentityUpgradeable.sol:FleetIdentityUpgradeable \\ + --verifier-url https://zksync2-mainnet-explorer.zksync.io/contract_verification + + # Verify with constructor args: + python3 ops/verify_zksync_contracts.py \\ + --address 0x1234... \\ + --contract lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy \\ + --constructor-args 0xabcdef... \\ + --verifier-url https://zksync2-mainnet-explorer.zksync.io/contract_verification + + # Verify all contracts from a broadcast JSON (batch mode): + python3 ops/verify_zksync_contracts.py \\ + --broadcast broadcast/DeploySwarmUpgradeableZkSync.s.sol/324/run-latest.json \\ + --verifier-url https://zksync2-mainnet-explorer.zksync.io/contract_verification + + # Use --compiler-version and --zksolc-version to override defaults: + python3 ops/verify_zksync_contracts.py ... --compiler-version 0.8.26 --zksolc-version v1.5.15 + +REQUIREMENTS: + - Python 3.8+ + - forge (foundry-zksync) on PATH + - No pip dependencies (stdlib only) +""" + +import argparse +import json +import os +import posixpath +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +DEFAULT_SOLC = "0.8.26" +DEFAULT_ZKSOLC = "v1.5.15" + +MAINNET_VERIFIER = ( + "https://zksync2-mainnet-explorer.zksync.io/contract_verification" +) +TESTNET_VERIFIER = ( + "https://explorer.sepolia.era.zksync.dev/contract_verification" +) + +# Map from Solidity script contract name → source path used in verification. +# Used by --broadcast mode to map broadcast JSON entries to verifiable contracts. +# Extend this when adding new contract types to the deploy script. +CONTRACT_SOURCE_MAP = { + "ServiceProviderUpgradeable": "src/swarms/ServiceProviderUpgradeable.sol:ServiceProviderUpgradeable", + "FleetIdentityUpgradeable": "src/swarms/FleetIdentityUpgradeable.sol:FleetIdentityUpgradeable", + "SwarmRegistryUniversalUpgradeable": "src/swarms/SwarmRegistryUniversalUpgradeable.sol:SwarmRegistryUniversalUpgradeable", + "ERC1967Proxy": "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy", + "BondTreasuryPaymaster": "src/paymasters/BondTreasuryPaymaster.sol:BondTreasuryPaymaster", +} + + +# --------------------------------------------------------------------------- +# Core logic +# --------------------------------------------------------------------------- +def resolve_import(current_file: str, import_path: str) -> str: + """Resolve a relative import like ../../utils/Foo.sol to an absolute project path.""" + if not import_path.startswith("."): + return import_path + current_dir = posixpath.dirname(current_file) + return posixpath.normpath(posixpath.join(current_dir, import_path)) + + +def fix_traversal_imports(std_json: dict) -> int: + """Rewrite ../ imports in source content to resolved absolute paths. + + Returns the number of import lines rewritten. + """ + source_keys = set(std_json["sources"].keys()) + changed = 0 + pattern = re.compile(r'(import\s+.*?["\'])(\.\./[^"\']+)(["\'].*)') + + for src_key in list(std_json["sources"].keys()): + content = std_json["sources"][src_key]["content"] + lines = content.split("\n") + new_lines = [] + for line in lines: + m = pattern.match(line) + if m: + prefix, rel_path, suffix = m.groups() + abs_path = resolve_import(src_key, rel_path) + if abs_path in source_keys: + new_lines.append(prefix + abs_path + suffix) + changed += 1 + continue + new_lines.append(line) + std_json["sources"][src_key]["content"] = "\n".join(new_lines) + + return changed + + +def generate_standard_json( + address: str, + contract: str, + constructor_args: str, + solc_version: str, + project_root: str, +) -> dict: + """Run forge verify-contract --show-standard-json-input and return parsed JSON.""" + cmd = [ + "forge", + "verify-contract", + "--zksync", + "--verifier", + "zksync", + "--show-standard-json-input", + "--compiler-version", + solc_version, + ] + if constructor_args and constructor_args != "0x": + cmd.extend(["--constructor-args", constructor_args]) + cmd.extend([address, contract]) + + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=project_root + ) + if result.returncode != 0: + raise RuntimeError( + f"forge verify-contract --show-standard-json-input failed:\n{result.stderr}" + ) + return json.loads(result.stdout) + + +def submit_verification( + verifier_url: str, + address: str, + contract: str, + std_json: dict, + constructor_args: str, + solc_version: str, + zksolc_version: str, +) -> str: + """POST to ZkSync verification API. Returns verification ID.""" + payload = { + "contractAddress": address, + "sourceCode": std_json, + "codeFormat": "solidity-standard-json-input", + "contractName": contract, + "compilerZksolcVersion": zksolc_version, + "compilerSolcVersion": solc_version, + "optimizationUsed": True, + "constructorArguments": constructor_args if constructor_args else "0x", + } + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + verifier_url, data=data, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + return resp.read().decode().strip() + except urllib.error.HTTPError as e: + body = e.read().decode() + raise RuntimeError(f"Verification API returned {e.code}: {body}") + + +def check_verification_status(verifier_url: str, vid: str) -> str: + """Poll verification status. Returns 'successful', 'failed', or 'in_progress'.""" + url = f"{verifier_url}/{vid}" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode()) + return data.get("status", "unknown") + + +def verify_one( + address: str, + contract: str, + constructor_args: str, + verifier_url: str, + solc_version: str, + zksolc_version: str, + project_root: str, + label: str = "", +) -> bool: + """Verify a single contract. Returns True on success.""" + display = label or contract.split(":")[-1] + print(f" [{display}] {address}") + + # Step 1: Generate standard JSON + print(f" Generating standard JSON input...", end=" ", flush=True) + std_json = generate_standard_json( + address, contract, constructor_args, solc_version, project_root + ) + fixes = fix_traversal_imports(std_json) + print(f"done (rewrote {fixes} imports)") + + # Step 2: Submit + print(f" Submitting to verifier...", end=" ", flush=True) + vid = submit_verification( + verifier_url, + address, + contract, + std_json, + constructor_args, + solc_version, + zksolc_version, + ) + print(f"ID: {vid}") + + # Step 3: Poll for result + for attempt in range(12): + time.sleep(5) + status = check_verification_status(verifier_url, vid) + if status == "successful": + print(f" ✓ Verified") + return True + elif status == "failed": + print(f" ✗ Verification failed") + return False + print(f" ... {status} (attempt {attempt + 1})", flush=True) + + print(f" ✗ Timed out waiting for verification") + return False + + +# --------------------------------------------------------------------------- +# Broadcast mode: extract contracts + constructor args from forge broadcast +# --------------------------------------------------------------------------- +def parse_broadcast(broadcast_path: str) -> list: + """Parse broadcast JSON and return list of (address, contract_name, ctor_args_hex).""" + with open(broadcast_path) as f: + data = json.load(f) + + results = [] + for tx in data["transactions"]: + contract_name = tx.get("contractName", "") + address = tx.get("contractAddress", "") + if not address: + additional = tx.get("additionalContracts") or [] + if additional: + address = additional[0].get("address", "") + if not address or not contract_name: + continue + + # Extract constructor args from ZkSync ContractDeployer input + inp = tx["transaction"].get("input", "") + ctor_hex = "0x" + if inp.startswith("0x9c4d535b"): + payload = inp[10:] # skip 0x + selector + offset_hex = payload[128:192] + offset = int(offset_hex, 16) + ctor_start = offset * 2 + ctor_len = int(payload[ctor_start : ctor_start + 64], 16) + if ctor_len > 0: + ctor_hex = "0x" + payload[ + ctor_start + 64 : ctor_start + 64 + ctor_len * 2 + ] + + source = CONTRACT_SOURCE_MAP.get(contract_name) + if not source: + print( + f" [WARNING] Unknown contract '{contract_name}' at {address} " + f"— add it to CONTRACT_SOURCE_MAP in this script" + ) + continue + + results.append((address, source, ctor_hex, contract_name)) + + return results + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def main(): + parser = argparse.ArgumentParser( + description="Verify ZkSync Era contracts with full source-code matching.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + # Single-contract mode + parser.add_argument("--address", help="Contract address to verify") + parser.add_argument( + "--contract", + help="Contract identifier (e.g., src/Foo.sol:Foo)", + ) + parser.add_argument( + "--constructor-args", + default="0x", + help="ABI-encoded constructor args (hex, 0x-prefixed). Default: 0x", + ) + + # Batch mode + parser.add_argument( + "--broadcast", + help="Path to forge broadcast JSON for batch verification", + ) + + # Common options + parser.add_argument( + "--verifier-url", + default=MAINNET_VERIFIER, + help=f"ZkSync verification API URL. Default: mainnet", + ) + parser.add_argument( + "--compiler-version", + default=DEFAULT_SOLC, + help=f"Solc version (default: {DEFAULT_SOLC})", + ) + parser.add_argument( + "--zksolc-version", + default=DEFAULT_ZKSOLC, + help=f"zksolc version (default: {DEFAULT_ZKSOLC})", + ) + parser.add_argument( + "--project-root", + default=None, + help="Project root directory (default: auto-detect from git)", + ) + + args = parser.parse_args() + + # Determine project root + project_root = args.project_root + if not project_root: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + project_root = result.stdout.strip() + else: + project_root = os.getcwd() + + # Validate mode + if not args.broadcast and not (args.address and args.contract): + parser.error( + "Either --broadcast or both --address and --contract are required" + ) + + print(f"ZkSync Contract Verification") + print(f" Verifier: {args.verifier_url}") + print(f" Solc: {args.compiler_version} zksolc: {args.zksolc_version}") + print(f" Project: {project_root}") + print() + + if args.broadcast: + # Batch mode + contracts = parse_broadcast( + os.path.join(project_root, args.broadcast) + if not os.path.isabs(args.broadcast) + else args.broadcast + ) + print(f"Found {len(contracts)} contracts in broadcast\n") + + success = 0 + failed = 0 + for address, source, ctor, name in contracts: + try: + ok = verify_one( + address=address, + contract=source, + constructor_args=ctor, + verifier_url=args.verifier_url, + solc_version=args.compiler_version, + zksolc_version=args.zksolc_version, + project_root=project_root, + label=name, + ) + if ok: + success += 1 + else: + failed += 1 + except Exception as e: + print(f" ✗ Error: {e}") + failed += 1 + + print(f"\n{'='*50}") + print(f"Results: {success} verified, {failed} failed") + sys.exit(1 if failed > 0 else 0) + + else: + # Single contract mode + try: + ok = verify_one( + address=args.address, + contract=args.contract, + constructor_args=args.constructor_args, + verifier_url=args.verifier_url, + solc_version=args.compiler_version, + zksolc_version=args.zksolc_version, + project_root=project_root, + ) + sys.exit(0 if ok else 1) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() From b40a0a8191badaeffdd52276ddad946f7430f71c Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 10 Apr 2026 16:29:00 +1200 Subject: [PATCH 5/5] Consolidate instruction files: move Ownable2Step guidance to copilot-instructions.md, remove Cursor-specific .agent/rules/ --- .agent/rules/solidity_zksync.md | 33 --------------------------------- .github/copilot-instructions.md | 3 ++- 2 files changed, 2 insertions(+), 34 deletions(-) delete mode 100644 .agent/rules/solidity_zksync.md diff --git a/.agent/rules/solidity_zksync.md b/.agent/rules/solidity_zksync.md deleted file mode 100644 index 642f1082..00000000 --- a/.agent/rules/solidity_zksync.md +++ /dev/null @@ -1,33 +0,0 @@ -# Solidity & ZkSync Development Standards - -## Toolchain & Environment -- **Primary Tool**: `forge` (ZkSync fork). Use for compilation, testing, and generic scripting. -- **Secondary Tool**: `hardhat`. Use only when `forge` encounters compatibility issues (e.g., complex deployments, specific plugin needs). -- **Network Target**: ZkSync Era (Layer 2). -- **Solidity Version**: `^0.8.20` (or `0.8.24` if strictly supported by the zk-compiler). - -## Modern Solidity Best Practices -- **Safety First**: - - **Checks-Effects-Interactions (CEI)** pattern must be strictly followed. - - When a contract requires an owner (e.g., admin-configurable parameters), prefer `Ownable2Step` over `Ownable`. Do **not** add ownership to contracts that don't need it — many contracts are fully permissionless by design. - - Prefer `ReentrancyGuard` for external calls where appropriate. -- **Gas & Efficiency**: - - Use **Custom Errors** (`error MyError();`) instead of `require` strings. - - Use `mapping` over arrays for membership checks where possible. - - Minimize on-chain storage; use events for off-chain indexing. - -## Testing Standards -- **Framework**: Foundry (Forge). -- **Methodology**: - - **Unit Tests**: Comprehensive coverage for all functions. - - **Fuzz Testing**: Required for arithmetic and purely functional logic. - - **Invariant Testing**: Define invariants for stateful system properties. -- **Naming Convention**: - - `test_Description` - - `testFuzz_Description` - - `test_RevertIf_Condition` - -## ZkSync Specifics -- **System Contracts**: Be aware of ZkSync system contracts (e.g., `ContractDeployer`, `L2EthToken`) when interacting with low-level features. -- **Gas Model**: Account for ZkSync's different gas metering if performing low-level optimization. -- **Compiler Differences**: Be mindful of differences between `solc` and `zksolc` (e.g., `create2` address derivation). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fd5ebcec..bee18907 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,7 +11,7 @@ - **Safety First**: - **Checks-Effects-Interactions (CEI)** pattern must be strictly followed. - - Use `Ownable2Step` over `Ownable` for privileged access. + - When a contract requires an owner (e.g., admin-configurable parameters), prefer `Ownable2Step` over `Ownable`. Do **not** add ownership to contracts that don't need it — many contracts are fully permissionless by design. - Prefer `ReentrancyGuard` for external calls where appropriate. - **Gas & Efficiency**: - Use **Custom Errors** (`error MyError();`) instead of `require` strings. @@ -62,6 +62,7 @@ forge test --match-path test/SwarmRegistryL1.t.sol ### The Solution Use `ops/verify_zksync_contracts.py` which: + 1. Generates standard JSON via `forge verify-contract --show-standard-json-input` 2. Rewrites all `../` relative imports in OpenZeppelin source content to resolved project-absolute paths (e.g., `../../utils/Foo.sol` → `lib/openzeppelin-contracts/contracts/utils/Foo.sol`) 3. Submits directly to the ZkSync verification API via HTTP