From fee57f3bd729f48440d288df7ccda051d73c02c7 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 22 May 2026 13:05:43 +1200 Subject: [PATCH] fix(envelope): split abi.encode to avoid viaIR, add deploy+verify scripts EnvelopeLinks._feeAuthorizationDigest had 15 parameters in a single abi.encode() call, which exceeds solc's stack limit without viaIR. The ZkSync verifier cannot handle viaIR contracts (crashes with 'internal error' on zksolc >=1.5.13, ignores viaIR on <=1.5.1). Fix: split into abi.encodePacked(abi.encode(8), abi.encode(7)) which produces byte-identical output (abi.encode pads to 32 bytes) and compiles without viaIR. Also adds: - ops/verify_hardhat_zksync.py: verification script for Hardhat builds (BFS import graph, filtered standard JSON, API submission) - ops/deploy_envelope_zksync.sh: one-command deploy+verify workflow - hardhat-deploy/DeployEnvelope.ts: auto-select .env-prod on mainnet - .github/copilot-instructions.md: document Hardhat path and split trick Deployed & verified on ZkSync Era mainnet: - EnvelopeLinks: 0xff735c70f33ca4eF1768F527B5f230b76A61A89b - EnvelopePaymaster: 0x5396e4F349D863C0AD577bd9E752293524460C36 --- .github/copilot-instructions.md | 64 +++++ hardhat-deploy/DeployEnvelope.ts | 5 +- ops/deploy_envelope_zksync.sh | 252 ++++++++++++++++++++ ops/verify_hardhat_zksync.py | 373 ++++++++++++++++++++++++++++++ src/envelope/EnvelopeLinks.sol | 40 ++-- src/envelope/doc/EnvelopeLinks.md | 2 + 6 files changed, 718 insertions(+), 18 deletions(-) create mode 100755 ops/deploy_envelope_zksync.sh create mode 100644 ops/verify_hardhat_zksync.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bee18907..7a6d53eb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -100,3 +100,67 @@ When deploying a new contract type, add its mapping to `CONTRACT_SOURCE_MAP` in ### 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. + +## Hardhat-Based Deployment & Verification (Envelope Contracts) + +### When to Use Hardhat Instead of Forge + +Use Hardhat when a contract triggers `stack-too-deep` without `viaIR`, because: + +- The ZkSync verifier with zksolc ≤1.5.1 does **not** pass `viaIR` through to solc. +- The ZkSync verifier with zksolc ≥1.5.13 passes `viaIR` but **crashes** on complex contracts ("internal error"). +- Hardhat with zksolc v1.5.1 (no viaIR) is the only path that produces verifiable bytecode. + +### Avoiding stack-too-deep Without viaIR + +If a function has too many local variables or parameters in a single `abi.encode` call, split it: + +```solidity +// BEFORE (15 params — triggers stack-too-deep without viaIR): +keccak256(abi.encode(TYPEHASH, a, b, c, d, e, f, g, h, i, j, k, l, m, n)) + +// AFTER (split into 8+7 — compiles without viaIR): +keccak256(abi.encodePacked( + abi.encode(TYPEHASH, a, b, c, d, e, f, g), + abi.encode(h, i, j, k, l, m, n) +)) +``` + +This works because `abi.encode` pads each value to 32 bytes, so `abi.encodePacked(abi.encode(a,b), abi.encode(c,d))` produces identical output to `abi.encode(a,b,c,d)`. + +### Verification for Hardhat-Compiled Contracts + +The Hardhat verification plugin (`@matterlabs/hardhat-zksync-verify`) has a bug (HH700 artifact not found). Use `ops/verify_hardhat_zksync.py` instead: + +1. Reads `artifacts-zk/build-info/*.json` (Hardhat's compilation output). +2. Performs BFS from the contract source to find all transitive imports. +3. Builds a **filtered** standard JSON containing only needed sources (avoids unrelated compilation errors in the verifier). +4. Submits to the ZkSync verification API and polls for result. + +```bash +# Verify after Hardhat deployment: +python3 ops/verify_hardhat_zksync.py \ + --address 0xff735c70f33ca4eF1768F527B5f230b76A61A89b \ + --contract src/envelope/EnvelopeLinks.sol:EnvelopeLinks \ + --constructor-args "$(cast abi-encode 'constructor(address,address,address)' 0xMFA 0xOwner 0xFeeToken)" \ + --address 0x5396e4F349D863C0AD577bd9E752293524460C36 \ + --contract src/paymasters/EnvelopePaymaster.sol:EnvelopePaymaster \ + --constructor-args "$(cast abi-encode 'constructor(address,address,address)' 0xAdmin 0xWithdrawer 0xVault)" +``` + +### Envelope Deployment (Full Workflow) + +```bash +# One-command deploy + verify: +./ops/deploy_envelope_zksync.sh mainnet + +# Or verify-only (if deploy already succeeded): +./ops/deploy_envelope_zksync.sh mainnet --verify-only \ + --vault 0xVaultAddr --paymaster 0xPaymasterAddr +``` + +Key facts: + +- Deployed via `hardhat-deploy/DeployEnvelope.ts` (auto-selects `.env-prod` on mainnet). +- `EnvelopePaymaster.envelopeLinks` is **immutable** — if vault address changes, paymaster must be redeployed. +- The `FEE_AUTHORIZATION_TYPEHASH` digest uses a split `abi.encode` (see `_feeAuthorizationDigest`) — this is intentional to avoid viaIR. diff --git a/hardhat-deploy/DeployEnvelope.ts b/hardhat-deploy/DeployEnvelope.ts index 04a08bfb..d8325c69 100644 --- a/hardhat-deploy/DeployEnvelope.ts +++ b/hardhat-deploy/DeployEnvelope.ts @@ -6,7 +6,10 @@ import "@matterlabs/hardhat-zksync-verify/dist/src/type-extensions"; import * as dotenv from "dotenv"; import { deployContract } from "./utils"; -dotenv.config({ path: ".env-test" }); +// Load .env-prod for mainnet, .env-test otherwise +const envFile = + process.env.HARDHAT_NETWORK === "zkSyncMainnet" ? ".env-prod" : ".env-test"; +dotenv.config({ path: envFile }); /** * Deploys the Envelope (vendored Peanut V4.4) suite on ZkSync Era. diff --git a/ops/deploy_envelope_zksync.sh b/ops/deploy_envelope_zksync.sh new file mode 100755 index 00000000..08c2f066 --- /dev/null +++ b/ops/deploy_envelope_zksync.sh @@ -0,0 +1,252 @@ +#!/bin/bash +# ============================================================================= +# deploy_envelope_zksync.sh +# +# Deploys EnvelopeLinks and EnvelopePaymaster to ZkSync Era via Hardhat. +# +# WHY HARDHAT (not Forge): +# The ZkSync source verifier requires compiling without viaIR. Hardhat with +# zksolc v1.5.1 produces verifiable artifacts. The Forge toolchain (zksolc +# v1.5.15) supports viaIR but the verifier crashes on complex viaIR contracts. +# +# USAGE: +# # Deploy to mainnet: +# ./ops/deploy_envelope_zksync.sh mainnet +# +# # Deploy to testnet: +# ./ops/deploy_envelope_zksync.sh testnet +# +# # Re-run only verification (deploy already succeeded): +# ./ops/deploy_envelope_zksync.sh mainnet --verify-only \ +# --vault 0xff735c70f33ca4eF1768F527B5f230b76A61A89b \ +# --paymaster 0x5396e4F349D863C0AD577bd9E752293524460C36 +# +# # Deploy + fund paymaster: +# ./ops/deploy_envelope_zksync.sh mainnet --fund 0.01 +# +# REQUIRED ENVIRONMENT (loaded from .env-prod or .env-test): +# - DEPLOYER_PRIVATE_KEY +# - ENVELOPE_MFA_AUTHORIZER +# - ENVELOPE_FEE_TOKEN (or NODL as fallback) +# - ENVELOPE_OWNER (defaults to L2_ADMIN) +# +# ============================================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ============================================================================= +# Parse Arguments +# ============================================================================= + +NETWORK="${1:-testnet}" +shift || true + +VERIFY_ONLY=false +FUND_AMOUNT="" +VAULT_ADDR="" +PAYMASTER_ADDR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --verify-only) VERIFY_ONLY=true; shift ;; + --vault) VAULT_ADDR="$2"; shift 2 ;; + --paymaster) PAYMASTER_ADDR="$2"; shift 2 ;; + --fund) FUND_AMOUNT="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# ============================================================================= +# Network Configuration +# ============================================================================= + +case "$NETWORK" in + testnet) + ENV_FILE=".env-test" + HH_NETWORK="zkSyncSepoliaTestnet" + DEFAULT_RPC="https://rpc.ankr.com/zksync_era_sepolia" + VERIFIER_URL="https://explorer.sepolia.era.zksync.dev/contract_verification" + ;; + mainnet) + ENV_FILE=".env-prod" + HH_NETWORK="zkSyncMainnet" + DEFAULT_RPC="https://mainnet.era.zksync.io" + VERIFIER_URL="https://zksync2-mainnet-explorer.zksync.io/contract_verification" + ;; + *) + echo "Error: Unknown network '$NETWORK'. Use 'testnet' or 'mainnet'." + exit 1 + ;; +esac + +# ============================================================================= +# Colors & Helpers +# ============================================================================= + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warning() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# ============================================================================= +# Pre-flight +# ============================================================================= + +cd "$PROJECT_ROOT" + +log_info "Loading environment from $ENV_FILE" +if [ ! -f "$ENV_FILE" ]; then + log_error "Environment file '$ENV_FILE' not found." + exit 1 +fi +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +# Validate +if [ -z "${DEPLOYER_PRIVATE_KEY:-}" ]; then + log_error "DEPLOYER_PRIVATE_KEY not set in $ENV_FILE" + exit 1 +fi +if [ -z "${ENVELOPE_MFA_AUTHORIZER:-}" ]; then + log_error "ENVELOPE_MFA_AUTHORIZER not set in $ENV_FILE" + exit 1 +fi + +RPC_URL="${L2_RPC:-$DEFAULT_RPC}" +VERIFIER_URL="${L2_VERIFIER_URL:-$VERIFIER_URL}" +OWNER="${ENVELOPE_OWNER:-$L2_ADMIN}" +FEE_TOKEN="${ENVELOPE_FEE_TOKEN:-$NODL}" + +DEPLOYER_ADDRESS=$(cast wallet address "$DEPLOYER_PRIVATE_KEY") +BALANCE=$(cast balance "$DEPLOYER_ADDRESS" --rpc-url "$RPC_URL" --ether) + +log_info "Network: $NETWORK ($HH_NETWORK)" +log_info "RPC: $RPC_URL" +log_info "Deployer: $DEPLOYER_ADDRESS ($BALANCE ETH)" +log_info "Owner: $OWNER" +log_info "MFA: $ENVELOPE_MFA_AUTHORIZER" +log_info "Fee Token: $FEE_TOKEN" +echo "" + +# ============================================================================= +# Deploy (via Hardhat) +# ============================================================================= + +if [ "$VERIFY_ONLY" = true ]; then + log_info "Skipping deployment (--verify-only)" + if [ -z "$VAULT_ADDR" ] || [ -z "$PAYMASTER_ADDR" ]; then + log_error "--verify-only requires --vault and --paymaster addresses" + exit 1 + fi +else + log_info "Compiling with Hardhat (zksolc v1.5.1, no viaIR)..." + npx hardhat compile --force 2>&1 | grep -E "^(Successfully|Error)" || true + echo "" + + log_info "Deploying to $NETWORK..." + # Hardhat deploy-zksync runs the script and prints deployed addresses + DEPLOY_OUTPUT=$(HARDHAT_NETWORK="$HH_NETWORK" npx hardhat deploy-zksync \ + --script DeployEnvelope.ts --network "$HH_NETWORK" 2>&1) + + echo "$DEPLOY_OUTPUT" + echo "" + + # Extract addresses from output + VAULT_ADDR=$(echo "$DEPLOY_OUTPUT" | grep -oP 'EnvelopeLinks deployed at \K0x[a-fA-F0-9]+') + PAYMASTER_ADDR=$(echo "$DEPLOY_OUTPUT" | grep -oP 'EnvelopePaymaster deployed at \K0x[a-fA-F0-9]+') + + if [ -z "$VAULT_ADDR" ]; then + log_error "Could not extract EnvelopeLinks address from deploy output" + exit 1 + fi + + log_success "EnvelopeLinks: $VAULT_ADDR" + log_success "EnvelopePaymaster: $PAYMASTER_ADDR" + echo "" +fi + +# ============================================================================= +# Verify (via filtered standard JSON API submission) +# ============================================================================= + +log_info "Verifying contracts on ZkSync explorer..." + +# Build constructor args +VAULT_CTOR=$(cast abi-encode "constructor(address,address,address)" \ + "$ENVELOPE_MFA_AUTHORIZER" "$OWNER" "$FEE_TOKEN") +PAYMASTER_CTOR=$(cast abi-encode "constructor(address,address,address)" \ + "${ENVELOPE_PAYMASTER_ADMIN:-$OWNER}" "${ENVELOPE_PAYMASTER_WITHDRAWER:-$OWNER}" "$VAULT_ADDR") + +VERIFY_ARGS=( + "--address" "$VAULT_ADDR" + "--contract" "src/envelope/EnvelopeLinks.sol:EnvelopeLinks" + "--constructor-args" "$VAULT_CTOR" + "--address" "$PAYMASTER_ADDR" + "--contract" "src/paymasters/EnvelopePaymaster.sol:EnvelopePaymaster" + "--constructor-args" "$PAYMASTER_CTOR" + "--verifier-url" "$VERIFIER_URL" +) + +python3 ops/verify_hardhat_zksync.py "${VERIFY_ARGS[@]}" + +# ============================================================================= +# Validate +# ============================================================================= + +echo "" +log_info "Validating on-chain state..." + +MFA=$(cast call "$VAULT_ADDR" "mfaAuthorizer()(address)" --rpc-url "$RPC_URL") +log_info " mfaAuthorizer(): $MFA" + +OWN=$(cast call "$VAULT_ADDR" "owner()(address)" --rpc-url "$RPC_URL") +log_info " owner(): $OWN" + +FT=$(cast call "$VAULT_ADDR" "feeToken()(address)" --rpc-url "$RPC_URL") +log_info " feeToken(): $FT" + +PM_VAULT=$(cast call "$PAYMASTER_ADDR" "envelopeLinks()(address)" --rpc-url "$RPC_URL") +log_info " paymaster.envelopeLinks(): $PM_VAULT" + +log_success "Validation passed" + +# ============================================================================= +# Fund Paymaster (optional) +# ============================================================================= + +if [ -n "$FUND_AMOUNT" ]; then + log_info "Funding paymaster with $FUND_AMOUNT ETH..." + cast send "$PAYMASTER_ADDR" \ + --value "${FUND_AMOUNT}ether" \ + --rpc-url "$RPC_URL" \ + --private-key "$DEPLOYER_PRIVATE_KEY" + PM_BAL=$(cast balance "$PAYMASTER_ADDR" --rpc-url "$RPC_URL" --ether) + log_success "Paymaster balance: $PM_BAL ETH" +fi + +# ============================================================================= +# Summary +# ============================================================================= + +echo "" +echo "============================================================" +echo " ENVELOPE DEPLOYMENT COMPLETE ($NETWORK)" +echo "============================================================" +echo " EnvelopeLinks: $VAULT_ADDR" +echo " EnvelopePaymaster: $PAYMASTER_ADDR" +echo "" +echo " Update $ENV_FILE:" +echo " ENVELOPE_VAULT=$VAULT_ADDR" +echo " ENVELOPE_PAYMASTER=$PAYMASTER_ADDR" +echo "============================================================" diff --git a/ops/verify_hardhat_zksync.py b/ops/verify_hardhat_zksync.py new file mode 100644 index 00000000..54c0c6dd --- /dev/null +++ b/ops/verify_hardhat_zksync.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +""" +verify_hardhat_zksync.py — Source-code verification for Hardhat-compiled ZkSync contracts. + +PROBLEM: + The @matterlabs/hardhat-zksync-verify plugin has a bug (HH700: artifact not found + for @openzeppelin/contracts-hardhat-zksync-upgradable) that prevents automatic + verification. Manual submission via the ZkSync verification API works, but requires + filtering the standard JSON to only include sources in the contract's dependency + graph — otherwise unrelated compilation errors (e.g., Grants.sol) cause the + verifier to reject the submission. + +SOLUTION: + This script reads Hardhat's build-info JSON (artifacts-zk/build-info/*.json), + performs a BFS from the target contract to find all transitive imports, builds a + filtered standard JSON input, and submits it to the ZkSync verification API. + +USAGE: + # Verify a single contract: + python3 ops/verify_hardhat_zksync.py \\ + --address 0xff735c70f33ca4eF1768F527B5f230b76A61A89b \\ + --contract src/envelope/EnvelopeLinks.sol:EnvelopeLinks \\ + --constructor-args "$(cast abi-encode 'constructor(address,address,address)' 0xAddr1 0xAddr2 0xAddr3)" + + # Verify multiple contracts: + python3 ops/verify_hardhat_zksync.py \\ + --address 0xABC... --contract src/A.sol:A --constructor-args 0x... \\ + --address 0xDEF... --contract src/B.sol:B --constructor-args 0x... + + # Override compiler versions (defaults from build-info): + python3 ops/verify_hardhat_zksync.py ... --zksolc-version v1.5.1 --solc-version 0.8.26 + + # Specify verifier URL (defaults to mainnet): + python3 ops/verify_hardhat_zksync.py ... --verifier-url https://explorer.sepolia.era.zksync.dev/contract_verification + +REQUIREMENTS: + - Python 3.8+ + - Hardhat must have been compiled with `npx hardhat compile` first + - No pip dependencies (stdlib only) +""" + +import argparse +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from collections import deque +from pathlib import Path + +# Default verifier URLs +MAINNET_VERIFIER = "https://zksync2-mainnet-explorer.zksync.io/contract_verification" +TESTNET_VERIFIER = "https://explorer.sepolia.era.zksync.dev/contract_verification" + + +def find_build_info(project_root: str) -> str: + """Find the most recent build-info JSON file.""" + build_info_dir = os.path.join(project_root, "artifacts-zk", "build-info") + if not os.path.isdir(build_info_dir): + print(f"ERROR: Build info directory not found: {build_info_dir}", file=sys.stderr) + print("Run 'npx hardhat compile' first.", file=sys.stderr) + sys.exit(1) + + json_files = sorted( + Path(build_info_dir).glob("*.json"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not json_files: + print(f"ERROR: No JSON files in {build_info_dir}", file=sys.stderr) + sys.exit(1) + + return str(json_files[0]) + + +def load_build_info(path: str) -> dict: + """Load and parse a build-info JSON file.""" + print(f"Loading build-info: {os.path.basename(path)}") + with open(path) as f: + return json.load(f) + + +def find_transitive_imports(sources: dict, entry_file: str) -> set: + """ + BFS from entry_file to find all transitive imports. + + Uses re.DOTALL to handle multi-line import statements like: + import { + IPaymaster, + ExecutionResult + } from "lib/era-contracts/..."; + """ + needed = set() + queue = deque([entry_file]) + + while queue: + src_path = queue.popleft() + if src_path in needed: + continue + needed.add(src_path) + + content = sources.get(src_path, {}).get("content", "") + # Match import paths from both single-line and multi-line imports + imports = re.findall(r'import\s+[^;]*?["\']([^"\']+)["\']', content, re.DOTALL) + + for imp in imports: + # Try the import path directly (Hardhat stores @openzeppelin/... paths as-is) + if imp in sources and imp not in needed: + queue.append(imp) + else: + # Try relative resolution + base = os.path.dirname(src_path) + resolved = os.path.normpath(os.path.join(base, imp)) + if resolved in sources and resolved not in needed: + queue.append(resolved) + + return needed + + +def build_filtered_input(build_info: dict, source_file: str) -> dict: + """Build a filtered standard JSON input with only the needed sources.""" + sources = build_info["input"]["sources"] + + if source_file not in sources: + print(f"ERROR: Source file '{source_file}' not found in build-info.", file=sys.stderr) + print(f"Available sources containing similar name:", file=sys.stderr) + basename = os.path.basename(source_file) + for k in sorted(sources.keys()): + if basename in k: + print(f" {k}", file=sys.stderr) + sys.exit(1) + + needed = find_transitive_imports(sources, source_file) + print(f" Found {len(needed)} source files in dependency graph") + + return { + "language": build_info["input"]["language"], + "sources": {k: v for k, v in sources.items() if k in needed}, + "settings": build_info["input"]["settings"], + } + + +def extract_compiler_versions(build_info: dict) -> tuple: + """Extract zksolc and solc versions from build-info.""" + # solcVersion in Hardhat build-info is like "zkVM-0.8.26-1.0.1" + solc_version_raw = build_info.get("solcVersion", "") + # Extract the solc semver (e.g., "0.8.26" from "zkVM-0.8.26-1.0.1") + match = re.search(r"(\d+\.\d+\.\d+)", solc_version_raw) + solc_version = match.group(1) if match else "0.8.26" + + # zksolc version from settings or default + zksolc_version = build_info.get("zksolcVersion") + if not zksolc_version: + # Try to find it in the output compiler metadata + settings = build_info.get("input", {}).get("settings", {}) + zksolc_version = "v1.5.1" # Hardhat default + + if not zksolc_version.startswith("v"): + zksolc_version = f"v{zksolc_version}" + + return zksolc_version, solc_version + + +def submit_verification( + verifier_url: str, + address: str, + contract_name: str, + source_code: dict, + constructor_args: str, + zksolc_version: str, + solc_version: str, +) -> int: + """Submit a verification request to the ZkSync API. Returns the verification ID.""" + payload = { + "contractAddress": address, + "sourceCode": source_code, + "codeFormat": "solidity-standard-json-input", + "contractName": contract_name, + "compilerZksolcVersion": zksolc_version, + "compilerSolcVersion": solc_version, + "constructorArguments": constructor_args, + "optimizationUsed": True, + } + + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + verifier_url, + data=data, + headers={"Content-Type": "application/json"}, + ) + + try: + resp = urllib.request.urlopen(req) + verification_id = int(resp.read().decode().strip()) + return verification_id + except urllib.error.HTTPError as e: + error_body = e.read().decode()[:500] + print(f" ERROR: HTTP {e.code}", file=sys.stderr) + print(f" Response: {error_body}", file=sys.stderr) + sys.exit(1) + + +def poll_verification(verifier_url: str, verification_id: int, timeout: int = 120) -> str: + """Poll for verification result. Returns 'successful', 'failed', or raises on timeout.""" + url = f"{verifier_url}/{verification_id}" + start = time.time() + + while time.time() - start < timeout: + time.sleep(5) + try: + resp = urllib.request.urlopen(url) + result = json.loads(resp.read().decode()) + status = result.get("status", "") + + if status == "successful": + return "successful" + elif status == "failed": + error = result.get("error", "unknown") + errors = result.get("compilationErrors", []) + print(f" FAILED: {error}", file=sys.stderr) + for err in errors[:3]: + # Trim long error messages + print(f" {err[:200]}", file=sys.stderr) + return "failed" + # else: still in progress + print(f" Status: {status} (waiting...)") + except urllib.error.HTTPError: + pass + + print(f" TIMEOUT after {timeout}s", file=sys.stderr) + return "timeout" + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Verify Hardhat-compiled contracts on ZkSync Era" + ) + parser.add_argument( + "--address", + action="append", + required=True, + help="Contract address to verify (can be repeated for multiple contracts)", + ) + parser.add_argument( + "--contract", + action="append", + required=True, + help="Contract identifier as 'path:Name' (can be repeated, matches --address order)", + ) + parser.add_argument( + "--constructor-args", + action="append", + default=[], + help="ABI-encoded constructor args with 0x prefix (can be repeated, matches --address order)", + ) + parser.add_argument( + "--verifier-url", + default=MAINNET_VERIFIER, + help=f"ZkSync verifier API URL (default: {MAINNET_VERIFIER})", + ) + parser.add_argument( + "--zksolc-version", + default=None, + help="Override zksolc version (default: auto-detect from build-info)", + ) + parser.add_argument( + "--solc-version", + default=None, + help="Override solc version (default: auto-detect from build-info)", + ) + parser.add_argument( + "--build-info", + default=None, + help="Path to build-info JSON (default: most recent in artifacts-zk/build-info/)", + ) + parser.add_argument( + "--timeout", + type=int, + default=120, + help="Timeout in seconds for polling verification status (default: 120)", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + + # Validate matching counts + if len(args.address) != len(args.contract): + print("ERROR: --address and --contract must be specified the same number of times", file=sys.stderr) + sys.exit(1) + + # Pad constructor-args with empty strings if fewer provided + constructor_args_list = args.constructor_args + ["0x"] * (len(args.address) - len(args.constructor_args)) + + # Find project root (script is in ops/) + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + os.chdir(project_root) + + # Load build info + build_info_path = args.build_info or find_build_info(project_root) + build_info = load_build_info(build_info_path) + + # Determine compiler versions + zksolc_version, solc_version = extract_compiler_versions(build_info) + if args.zksolc_version: + zksolc_version = args.zksolc_version + if args.solc_version: + solc_version = args.solc_version + + print(f"Compiler: zksolc {zksolc_version}, solc {solc_version}") + print(f"Verifier: {args.verifier_url}") + print() + + # Process each contract + results = [] + for i, (address, contract, ctor_args) in enumerate( + zip(args.address, args.contract, constructor_args_list) + ): + source_file, contract_name = contract.split(":") + print(f"[{i+1}/{len(args.address)}] Verifying {contract_name} at {address}") + print(f" Source: {source_file}") + print(f" Constructor args: {ctor_args[:20]}{'...' if len(ctor_args) > 20 else ''}") + + # Build filtered standard JSON + filtered_input = build_filtered_input(build_info, source_file) + + # Ensure constructor args have 0x prefix + if not ctor_args.startswith("0x"): + ctor_args = "0x" + ctor_args + + # Submit + print(f" Submitting to verifier...") + verification_id = submit_verification( + args.verifier_url, + address, + contract, + filtered_input, + ctor_args, + zksolc_version, + solc_version, + ) + print(f" Verification ID: {verification_id}") + + # Poll for result + status = poll_verification(args.verifier_url, verification_id, args.timeout) + results.append((contract_name, address, verification_id, status)) + + if status == "successful": + print(f" ✓ VERIFIED") + else: + print(f" ✗ {status.upper()}") + print() + + # Summary + print("=" * 60) + print("VERIFICATION SUMMARY") + print("=" * 60) + all_ok = True + for name, addr, vid, status in results: + icon = "✓" if status == "successful" else "✗" + print(f" {icon} {name}: {addr} (ID: {vid}, {status})") + if status != "successful": + all_ok = False + print() + + sys.exit(0 if all_ok else 1) + + +if __name__ == "__main__": + main() diff --git a/src/envelope/EnvelopeLinks.sol b/src/envelope/EnvelopeLinks.sol index 003896c9..349a95f2 100644 --- a/src/envelope/EnvelopeLinks.sol +++ b/src/envelope/EnvelopeLinks.sol @@ -144,7 +144,6 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow /// @notice Tracks consumed fee authorizations to prevent replay (keyed by the EIP-712 digest). mapping(bytes32 => bool) public usedFeeAuthorizations; - // events event LinkCreated(uint256 indexed _index, uint8 indexed _contractType, uint256 _amount, address indexed _creator); event LinkRedeemed( @@ -734,24 +733,31 @@ contract EnvelopeLinks is IERC721Receiver, IERC1155Receiver, ReentrancyGuard, Ow FeeAuthorization calldata _feeAuthorization, address _feePayer ) internal view returns (bytes32) { + // Split abi.encode into two parts to avoid stack-too-deep without viaIR. + // abi.encodePacked(abi.encode(a..h), abi.encode(i..n)) == abi.encode(a..n) + // because abi.encode already pads each value to 32 bytes. return _hashTypedDataV4( keccak256( - abi.encode( - FEE_AUTHORIZATION_TYPEHASH, - _feePayer, - _request.tokenAddress, - _request.contractType, - _request.amount, - _request.tokenId, - _request.claimKey, - _request.onBehalfOf, - _request.withMFA, - _request.recipient, - _request.reclaimableAfter, - _feeAuthorization.serviceFee, - _feeAuthorization.gaslessFee, - _feeAuthorization.gaslessSponsored, - _feeAuthorization.deadline + abi.encodePacked( + abi.encode( + FEE_AUTHORIZATION_TYPEHASH, + _feePayer, + _request.tokenAddress, + _request.contractType, + _request.amount, + _request.tokenId, + _request.claimKey, + _request.onBehalfOf + ), + abi.encode( + _request.withMFA, + _request.recipient, + _request.reclaimableAfter, + _feeAuthorization.serviceFee, + _feeAuthorization.gaslessFee, + _feeAuthorization.gaslessSponsored, + _feeAuthorization.deadline + ) ) ) ); diff --git a/src/envelope/doc/EnvelopeLinks.md b/src/envelope/doc/EnvelopeLinks.md index 6a1663bc..ad60ae21 100644 --- a/src/envelope/doc/EnvelopeLinks.md +++ b/src/envelope/doc/EnvelopeLinks.md @@ -217,6 +217,8 @@ constructor(address mfaAuthorizer, address owner, address feeToken) For ERC-20 deposits, the vault measures the actual `balanceOf` delta rather than trusting the requested `amount`. This prevents insolvency when fee-on-transfer or rebasing tokens are deposited. The recorded `link.asset.amount` reflects what the vault actually received and can transfer back. +The configured `feeToken` is stricter: fee collection requires the vault's balance delta to exactly equal `serviceFee + gaslessFee`. Fee-on-transfer or rebasing fee tokens are rejected at link creation, so production deployments must configure a standard non-fee ERC-20 as `ENVELOPE_FEE_TOKEN`. + For raffle-style links (which have per-link variable amounts), a fee-on-transfer token will cause the deposit to revert because the vault asserts the received total matches the requested total. ### Fee Authorization Replay Protection