Skip to content

Commit 40d3af8

Browse files
authored
fix: use L2_ADMIN (ZkSync Safe) instead of NODL_ADMIN for L2 contract ownership (#109)
1 parent 6dcb888 commit 40d3af8

File tree

8 files changed

+549
-151
lines changed

8 files changed

+549
-151
lines changed

.agent/rules/solidity_zksync.md

Lines changed: 0 additions & 33 deletions
This file was deleted.

.github/copilot-instructions.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
- **Safety First**:
1313
- **Checks-Effects-Interactions (CEI)** pattern must be strictly followed.
14-
- Use `Ownable2Step` over `Ownable` for privileged access.
14+
- 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.
1515
- Prefer `ReentrancyGuard` for external calls where appropriate.
1616
- **Gas & Efficiency**:
1717
- Use **Custom Errors** (`error MyError();`) instead of `require` strings.
@@ -48,3 +48,55 @@ For these contracts, use:
4848
forge build --match-path src/swarms/SwarmRegistryL1.sol
4949
forge test --match-path test/SwarmRegistryL1.t.sol
5050
```
51+
52+
## ZkSync Source Code Verification
53+
54+
**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.
55+
56+
### The Problem (three broken paths)
57+
58+
1. `forge script --verify` sends **absolute file paths** (`/Users/me/project/src/...`) → verifier rejects.
59+
2. `forge verify-contract` (standard JSON) sends OpenZeppelin sources containing `../` relative imports → verifier rejects "import with absolute or traversal path".
60+
3. `forge verify-contract --flatten` or manual flattening eliminates imports but changes the source file path in the metadata hash → **"partially verified"** (metadata mismatch).
61+
62+
### The Solution
63+
64+
Use `ops/verify_zksync_contracts.py` which:
65+
66+
1. Generates standard JSON via `forge verify-contract --show-standard-json-input`
67+
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`)
68+
3. Submits directly to the ZkSync verification API via HTTP
69+
70+
### Full vs Partial Verification
71+
72+
- **`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**.
73+
- 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.
74+
75+
### Usage
76+
77+
```bash
78+
# After deployment — verify all contracts from broadcast:
79+
python3 ops/verify_zksync_contracts.py \
80+
--broadcast broadcast/DeploySwarmUpgradeableZkSync.s.sol/324/run-latest.json \
81+
--verifier-url https://zksync2-mainnet-explorer.zksync.io/contract_verification
82+
83+
# Verify a single contract:
84+
python3 ops/verify_zksync_contracts.py \
85+
--address 0x1234... \
86+
--contract src/swarms/FleetIdentityUpgradeable.sol:FleetIdentityUpgradeable \
87+
--verifier-url https://zksync2-mainnet-explorer.zksync.io/contract_verification
88+
89+
# With constructor args:
90+
python3 ops/verify_zksync_contracts.py \
91+
--address 0x1234... \
92+
--contract lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy \
93+
--constructor-args 0xabcdef...
94+
```
95+
96+
### Adding New Contract Types
97+
98+
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.
99+
100+
### Automated (via deploy script)
101+
102+
`ops/deploy_swarm_contracts_zksync.sh` calls `verify_zksync_contracts.py` automatically after deployment. No manual steps needed for the standard swarm contracts.

foundry.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ via_ir = true
1111
optimizer = true
1212
optimizer_runs = 200
1313

14+
# Omit CBOR metadata hash from bytecode. Required for full (not partial) source
15+
# verification on ZkSync explorer. Without this, the metadata hash includes source
16+
# file paths, and the ZkSync verifier rejects OpenZeppelin's "../" relative imports,
17+
# forcing flattened/rewritten sources that produce a different metadata hash.
18+
bytecode_hash = "none"
19+
1420
[lint]
1521
# Exclude ERC20 transfer warning - false positive for ERC721.transferFrom in tests
1622
exclude_lints = ["erc20-unchecked-transfer"]
@@ -23,6 +29,7 @@ solc = "0.8.26"
2329
via_ir = true
2430
optimizer = true
2531
optimizer_runs = 200
32+
bytecode_hash = "none"
2633
# Exclude L1-only contracts that use SSTORE2/EXTCODECOPY
2734
ignored_error_codes = []
2835
ignored_warnings_from = []

hardhat-deploy/DeploySwarmUpgradeable.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dotenv.config({ path: ".env-test" });
1616
* - DEPLOYER_PRIVATE_KEY: Private key for deployment
1717
* - BOND_TOKEN: Address of the ERC20 bond token
1818
* - BASE_BOND: Base bond amount in wei
19-
* - OWNER: (optional) Owner address, defaults to deployer
19+
* - L2_ADMIN: Owner address for all deployed L2 contracts (ZkSync Safe multisig)
2020
*/
2121
module.exports = async function (hre: HardhatRuntimeEnvironment) {
2222
const bondToken = process.env.BOND_TOKEN!;
@@ -28,7 +28,12 @@ module.exports = async function (hre: HardhatRuntimeEnvironment) {
2828
const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider);
2929
const deployer = new Deployer(hre, wallet);
3030

31-
const owner = process.env.OWNER || wallet.address;
31+
const owner = process.env.L2_ADMIN;
32+
if (!owner) {
33+
throw new Error(
34+
"L2_ADMIN environment variable is required (ZkSync Safe multisig)",
35+
);
36+
}
3237

3338
console.log("=== Deploying Upgradeable Swarm Contracts on ZkSync ===");
3439
console.log("Bond Token:", bondToken);

ops/deploy_swarm_contracts_zksync.sh

Lines changed: 38 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@
4949
# - NODL: Address of the NODL token (used as bond token)
5050
# - FLEET_OPERATOR: Address of the backend swarm operator (whitelisted user)
5151
# - BASE_BOND: Bond amount in wei (e.g., 100000000000000000000 for 100 NODL)
52-
# - NODL_ADMIN: (optional) Owner address for all deployed contracts, defaults to deployer
53-
# - PAYMASTER_WITHDRAWER: (optional) Address allowed to withdraw tokens from paymaster, defaults to NODL_ADMIN
52+
# - L2_ADMIN: Owner address for all deployed L2 contracts (ZkSync Safe multisig)
53+
# - PAYMASTER_WITHDRAWER: (optional) Address allowed to withdraw tokens from paymaster, defaults to L2_ADMIN
5454
# - COUNTRY_MULTIPLIER: (optional) Country multiplier for bond calculation (0 = use default)
5555
# - BOND_QUOTA: (optional) Max bond amount sponsorable per period in wei
5656
# - BOND_PERIOD: (optional) Quota renewal period in seconds
@@ -183,8 +183,11 @@ preflight_checks() {
183183
# Set defaults
184184
export BOND_TOKEN="${BOND_TOKEN:-$NODL}"
185185
export BASE_BOND="${BASE_BOND:-1000000000000000000000}" # 1000 NODL default
186-
export NODL_ADMIN="${NODL_ADMIN:-$DEPLOYER_ADDRESS}"
187-
export PAYMASTER_WITHDRAWER="${PAYMASTER_WITHDRAWER:-$NODL_ADMIN}"
186+
if [ -z "$L2_ADMIN" ]; then
187+
log_error "L2_ADMIN not set in $ENV_FILE (must be the ZkSync Safe multisig)"
188+
exit 1
189+
fi
190+
export PAYMASTER_WITHDRAWER="${PAYMASTER_WITHDRAWER:-$L2_ADMIN}"
188191
export BOND_QUOTA="${BOND_QUOTA:-100000000000000000000000}" # 100000 NODL default
189192
export BOND_PERIOD="${BOND_PERIOD:-86400}" # 1 day default
190193

@@ -339,7 +342,7 @@ deploy_contracts() {
339342
log_info "Would deploy with:"
340343
log_info " BOND_TOKEN: $BOND_TOKEN"
341344
log_info " BASE_BOND: $BASE_BOND"
342-
log_info " NODL_ADMIN: ${NODL_ADMIN:-deployer}"
345+
log_info " L2_ADMIN: $L2_ADMIN"
343346
log_info " PAYMASTER_WITHDRAWER: ${PAYMASTER_WITHDRAWER:-deployer}"
344347
log_info " FLEET_OPERATOR: $FLEET_OPERATOR"
345348
log_info " BOND_QUOTA: $BOND_QUOTA"
@@ -441,16 +444,18 @@ verify_deployment() {
441444
# =============================================================================
442445
#
443446
# Why a separate step:
444-
# forge script --verify sends absolute file paths (e.g. /Users/me/project/src/...)
445-
# which the ZkSync verifier rejects: "import with absolute or traversal path".
447+
# forge script --verify sends absolute file paths that ZkSync verifier rejects.
448+
# forge verify-contract sends standard JSON with "../" relative imports in
449+
# OpenZeppelin sources, which the verifier also rejects.
446450
#
447-
# Workaround:
448-
# 1. Flatten each contract into a single .sol file (no imports)
449-
# 2. Use forge verify-contract with the flattened file
450-
# 3. Clean up temporary flat files
451+
# Solution:
452+
# ops/verify_zksync_contracts.py generates standard JSON via forge, rewrites
453+
# all "../" relative imports to resolved absolute-within-project paths, then
454+
# submits directly to the ZkSync verification API.
451455
#
452-
# Constructor args are extracted from the broadcast JSON using the ZkSync
453-
# ContractDeployer ABI: create(bytes32 salt, bytes32 bytecodeHash, bytes ctorInput)
456+
# For contracts compiled with bytecode_hash = "none" (foundry.toml, added
457+
# 2026-04-10), this achieves FULL verification. For older contracts, it
458+
# achieves "partial" (metadata mismatch — cosmetic only, source is correct).
454459
#
455460
# =============================================================================
456461

@@ -461,12 +466,10 @@ verify_source_code() {
461466

462467
log_info "Verifying source code on block explorer..."
463468

464-
# Get RPC URL for chain detection
469+
# Determine chain ID for broadcast path
465470
if [ "$NETWORK" = "mainnet" ]; then
466-
RPC_URL="${L2_RPC:-https://mainnet.era.zksync.io}"
467471
CHAIN_ID="324"
468472
else
469-
RPC_URL="${L2_RPC:-https://rpc.ankr.com/zksync_era_sepolia}"
470473
CHAIN_ID="300"
471474
fi
472475

@@ -477,97 +480,26 @@ verify_source_code() {
477480
return 1
478481
fi
479482

480-
# Extract constructor args from broadcast JSON
481-
# ZkSync ContractDeployer.create(): 0x9c4d535b + salt(32) + hash(32) + offset_to_ctor(32) + len(32) + ctor_data
482-
log_info "Extracting constructor args from broadcast..."
483-
CTOR_ARGS=$(python3 -c "
484-
import json, sys
485-
with open('$BROADCAST_JSON') as f:
486-
data = json.load(f)
487-
for tx in data['transactions']:
488-
addr = (tx.get('additionalContracts') or [{}])[0].get('address', '')
489-
inp = tx['transaction'].get('input', '')
490-
payload = inp[10:] # skip 0x + 9c4d535b
491-
offset = int(payload[128:192], 16)
492-
ctor_start = offset * 2
493-
ctor_len = int(payload[ctor_start:ctor_start+64], 16)
494-
ctor_args = payload[ctor_start+64:ctor_start+64+ctor_len*2]
495-
print(f'{addr}:{ctor_args}')
496-
")
497-
498-
# Build lookup of address -> constructor args
499-
declare -A CTOR_MAP
500-
while IFS=: read -r addr args; do
501-
CTOR_MAP["$addr"]="$args"
502-
done <<< "$CTOR_ARGS"
503-
504-
# Create temporary directory for flattened sources
505-
FLAT_DIR=$(mktemp -d)
506-
507-
# Flatten all unique contract sources
508-
log_info "Flattening contract sources..."
509-
forge flatten src/swarms/ServiceProviderUpgradeable.sol > "$FLAT_DIR/FlatSP.sol"
510-
forge flatten src/swarms/FleetIdentityUpgradeable.sol > "$FLAT_DIR/FlatFI.sol"
511-
forge flatten src/swarms/SwarmRegistryUniversalUpgradeable.sol > "$FLAT_DIR/FlatSR.sol"
512-
forge flatten lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol > "$FLAT_DIR/FlatProxy.sol"
513-
forge flatten src/paymasters/BondTreasuryPaymaster.sol > "$FLAT_DIR/FlatBTP.sol"
514-
515-
# Copy flat files into src/ so forge can find them
516-
cp "$FLAT_DIR/FlatSP.sol" src/FlatSP.sol
517-
cp "$FLAT_DIR/FlatFI.sol" src/FlatFI.sol
518-
cp "$FLAT_DIR/FlatSR.sol" src/FlatSR.sol
519-
cp "$FLAT_DIR/FlatProxy.sol" src/FlatProxy.sol
520-
cp "$FLAT_DIR/FlatBTP.sol" src/FlatBTP.sol
521-
522-
VERIFY_FAILED=0
523-
524-
# Helper to verify a single contract
525-
verify_one() {
526-
local address="$1"
527-
local source="$2"
528-
local label="$3"
529-
local ctor_key
530-
ctor_key=$(echo "$address" | tr '[:upper:]' '[:lower:]')
531-
local args="${CTOR_MAP[$ctor_key]}"
532-
533-
local VARGS=(
534-
--zksync
535-
--chain "$FORGE_CHAIN"
536-
--verifier zksync
537-
--verifier-url "$VERIFIER_URL"
538-
"$address"
539-
"$source"
540-
)
541-
if [ -n "$args" ]; then
542-
VARGS+=(--constructor-args "$args")
543-
fi
483+
# Check python3 is available
484+
if ! command -v python3 &> /dev/null; then
485+
log_error "python3 not found. Install Python 3.8+ for source code verification."
486+
log_warning "Skipping source code verification"
487+
return 1
488+
fi
544489

545-
log_info "Verifying $label at $address..."
546-
if forge verify-contract "${VARGS[@]}" 2>&1; then
547-
log_success "$label verified"
548-
else
549-
log_error "$label verification failed (can retry manually)"
550-
VERIFY_FAILED=$((VERIFY_FAILED + 1))
551-
fi
552-
}
553-
554-
# Verify all 7 contracts
555-
verify_one "$SERVICE_PROVIDER_IMPL" "src/FlatSP.sol:ServiceProviderUpgradeable" "ServiceProvider Implementation"
556-
verify_one "$SERVICE_PROVIDER_PROXY" "src/FlatProxy.sol:ERC1967Proxy" "ServiceProvider Proxy"
557-
verify_one "$FLEET_IDENTITY_IMPL" "src/FlatFI.sol:FleetIdentityUpgradeable" "FleetIdentity Implementation"
558-
verify_one "$FLEET_IDENTITY_PROXY" "src/FlatProxy.sol:ERC1967Proxy" "FleetIdentity Proxy"
559-
verify_one "$SWARM_REGISTRY_IMPL" "src/FlatSR.sol:SwarmRegistryUniversalUpgradeable" "SwarmRegistry Implementation"
560-
verify_one "$SWARM_REGISTRY_PROXY" "src/FlatProxy.sol:ERC1967Proxy" "SwarmRegistry Proxy"
561-
verify_one "$BOND_TREASURY_PAYMASTER" "src/FlatBTP.sol:BondTreasuryPaymaster" "BondTreasuryPaymaster"
562-
563-
# Clean up flat files from src/
564-
rm -f src/FlatSP.sol src/FlatFI.sol src/FlatSR.sol src/FlatProxy.sol src/FlatBTP.sol
565-
rm -rf "$FLAT_DIR"
566-
567-
if [ "$VERIFY_FAILED" -gt 0 ]; then
568-
log_warning "$VERIFY_FAILED contract(s) failed source verification (deployment itself succeeded)"
490+
python3 "$SCRIPT_DIR/verify_zksync_contracts.py" \
491+
--broadcast "$BROADCAST_JSON" \
492+
--verifier-url "$VERIFIER_URL" \
493+
--compiler-version "0.8.26" \
494+
--zksolc-version "v1.5.15" \
495+
--project-root "$PROJECT_ROOT"
496+
497+
local exit_code=$?
498+
if [ "$exit_code" -eq 0 ]; then
499+
log_success "All contracts source-code verified on block explorer!"
569500
else
570-
log_success "All 7 contracts source-code verified on block explorer!"
501+
log_warning "Some contracts failed source verification (deployment itself succeeded)"
502+
log_info "Retry manually: python3 ops/verify_zksync_contracts.py --broadcast $BROADCAST_JSON --verifier-url $VERIFIER_URL"
571503
fi
572504
}
573505

@@ -677,7 +609,7 @@ print_summary() {
677609
echo " Explorer: $EXPLORER_URL/address/$BOND_TREASURY_PAYMASTER"
678610
echo ""
679611
echo "Configuration:"
680-
echo " Owner: ${NODL_ADMIN:-deployer}"
612+
echo " Owner: $L2_ADMIN"
681613
echo " Withdrawer: ${PAYMASTER_WITHDRAWER:-deployer}"
682614
echo " Fleet Operator: $FLEET_OPERATOR"
683615
echo " Bond Token: $BOND_TOKEN"

0 commit comments

Comments
 (0)