Dual-stack contract workspace supporting both Foundry and Hardhat.
src/: shared Solidity sourcestest/foundry/: Forge teststest/hardhat/: Hardhat testsscript/: deployment or maintenance scripts
forge test -C .npm installnpm run test:hardhatnpm run buildnpm run deploy:localhostnpm run deploy:devnetnpm run deploy:keystore:localhostnpm run deploy:keystore:sepolianpm run deploy:keystore:ethereum
- Solidity version pinned to
0.8.24 - Optimizer enabled (
runs=200) - Hardhat source path points to
src/to share the same contracts with Foundry
After deployment, verify contracts on Etherscan:
# Hardhat-native verification (default)
npx hardhat verify-contracts --network <network>
# Or via Makefile (from repo root)
make verify-contracts NETWORK=sepolia
make verify-contracts-ethereum
# Foundry forge verify-contract (alternative)
ETHERSCAN_VERIFICATION_PROVIDER=foundry npx hardhat verify-contracts --network sepoliaAll settings are read from env vars:
ETHERSCAN_KEY: Primary Etherscan API key (required)SEPOLIA_ETHERSCAN_KEY: Override for Sepolia (falls back to ETHERSCAN_KEY)MAINNET_ETHERSCAN_KEY: Override for Ethereum mainnet (falls back to ETHERSCAN_KEY)ETHERSCAN_VERIFICATION_PROVIDER:hardhat(default) orfoundryETHERSCAN_VERIFICATION_MAX_RETRIES: Max retries for Hardhat verification (default: 3)
- Deploy脚本结束时,
deployments/<network>/下的 JSON 文件包含每个合约的部署元数据(address、constructorArgs、libraries) - Verify时,
verify-contractstask 自动扫描deployments/<network>/目录,读取部署信息 - 对每个合约(含
_Implementation,自动跳过_Proxy),按以下方式验证:- Hardhat(默认):调用
verify:verifytask,带自动重试 - Foundry(
ETHERSCAN_VERIFICATION_PROVIDER=foundry):构建forge verify-contract命令,用cast abi-encode编码构造参数
- Hardhat(默认):调用
验证覆盖的合约类型:
| 类型 | 例子 | 说明 |
|---|---|---|
| 普通合约 | StateManager, Bridge | 单独部署,直接验证 |
| 代理合约 | PsyAddressesProvider_Proxy | OpenZeppelin Transparent Proxy,验证构造函数参数(impl, admin, data) |
| 实现合约 | PsyAddressesProvider_Implementation | 验证实现合约的构造函数参数 |
参考实现:paraspace-core 的
tasks/dev/verifyContracts.ts→helpers/contracts-helpers.ts:verifyContracts(),支持 Hardhat 和 Foundry 双模式。
KEYSTORE_PATH: encrypted deployer keystore pathWALLET_PASSWORD: keystore passwordLOCALHOST_RPC_URL,SEPOLIA_RPC_URL, orETH_RPC_URL: network RPC URL- Optional per-network WETH envs in
helper-hardhat-config.ts(ETH_WETH,ARB_WETH, ...)
Direct deploy private keys are intentionally disabled. Use scripts/deploy-with-keystore.mjs or the deploy:keystore:* npm scripts.
- Network deploy config is loaded from
config/<network>.json - For non-local networks, do not use placeholder governance addresses (
0x...01,0x...02) adminis the initialDefaultProxyAdminowner and ACL default admin.- Set
owner,bridgeAdmin,routerAdmin, andstateManagerAdminexplicitly; do not assume they are interchangeable withadmin.
KEYSTORE_PATH=... WALLET_PASSWORD=... LOCALHOST_RPC_URL=http://127.0.0.1:8545 npm run deploy:keystore:localhost
KEYSTORE_PATH=... WALLET_PASSWORD=... SEPOLIA_RPC_URL=https://... npm run deploy:keystore:sepoliaKEYSTORE_PATH=... WALLET_PASSWORD=... ETH_RPC_URL=https://... npm run deploy:keystore:ethereum
StateManager.bridge == BridgeStateManager.proposer == expected proposerRouter.bridge == BridgeRouter.defaultERC20Gateway == ERC20GatewayRouter.ethGateway == ETHGatewayStateManager.zkVerifier != address(0)
StateManager.appendDeposit: only BridgeStateManager.finalize: only ProposerBridge.recordDeposit: disabled; canonical path is Router -> Gateway -> Bridge.recordDepositFromGatewayBridge.recordDepositFromGateway: caller must match Router-resolved gateway
Upgradeable production contracts use OpenZeppelin v5 transparent proxies owned by DefaultProxyAdmin.
Upgradeable deployment names:
PsyAddressesProviderPsyACLManagerStateManagerBridgeRouterERC20GatewayETHGatewayTokenFaucetManager
Governance executor:
ExecutorWithTimelockis deployed bydeploy/007c_deploy_timelock.ts.deploy/007d_grant_timelock_roles.tsgrantsBRIDGE_ADMIN_ROLEandSTATE_MANAGER_ADMIN_ROLEto the timelock whenGRANT_TIMELOCK_ROLES=1.deploy/007e_transfer_proxy_admin_to_timelock.tstransfersDefaultProxyAdminownership to the timelock whenTRANSFER_PROXY_ADMIN_TO_TIMELOCK=1.- Set
TIMELOCK_ADMINto the multisig address, or it defaults tocfg.owner.
Notes:
- Deploying
ExecutorWithTimelockalone does not hand over every permission. By default,cfg.adminremains the ACL default admin and theDefaultProxyAdminowner. GRANT_TIMELOCK_ROLES=1only grantsBRIDGE_ADMIN_ROLEandSTATE_MANAGER_ADMIN_ROLEto the timelock. It does not grant router admin, ACL default admin, or proxy-upgrade ownership by itself.TRANSFER_PROXY_ADMIN_TO_TIMELOCK=1is the separate cutover step for proxy upgrades. Without it, implementation upgrades can still be executed directly by the currentDefaultProxyAdminowner.state-manager:force-set-staterequires allNEW_*env vars together, includingNEW_LAST_FINALIZED_CHECKPOINT_IDandNEW_DEPOSIT_SUBTREE_ROOT. Upgrade modes useDRY_RUN:- Fork governance tests and forked upgrade scripts need a working
SEPOLIA_RPC_URL. If the default public RPC rate-limits or returns 403, override it explicitly, for exampleSEPOLIA_RPC_URL=https://sepolia.drpc.org. - unset: execute directly through the connected signer. This works only while
DefaultProxyAdminis still directly owned by that signer. Run: send the encoded transaction directly to the target contract. This also requires directDefaultProxyAdminownership for upgrades.TimeLock: print queue/execute/cancel calldata forExecutorWithTimelock.Safe: write an offline Safe proposal JSON underdeployments/<network>/safe-proposals/.SafeWithTimeLock: write a Safe proposal that targets the timelock calldata.- Once
TRANSFER_PROXY_ADMIN_TO_TIMELOCK=1has been applied, upgrades must go throughTimeLockorSafeWithTimeLock.
Examples:
# Activate full timelock governance for upgrade + rescue/force-set paths
GRANT_TIMELOCK_ROLES=1 \
TRANSFER_PROXY_ADMIN_TO_TIMELOCK=1 \
npx hardhat deploy --tags timelock_proxy_admin --network sepolia
# Encode a timelock queue operation for the in-place StateManager implementation
DRY_RUN=TimeLock npx hardhat upgrade --contract StateManager --network sepolia
# Upgrade Bridge to the current in-place implementation directly on a fork/local network
DRY_RUN=Run npx hardhat upgrade --contract Bridge --network localhost
# Encode all known proxy upgrades
DRY_RUN=TimeLock npx hardhat upgrade:all --network sepoliaStateManager force state repair after upgrading the in-place implementation:
DRY_RUN=TimeLock \
NEW_LAST_FINALIZED_CHECKPOINT_ID=100187 \
NEW_LAST_VERIFIED_CHECKPOINT_ROOT=0xe3f1bcc23eff84f7a1d2f71c91cfdcc5cd3947380970cbd49fe8663eb78e2b0a \
NEW_LAST_VERIFIED_DEPOSIT_TREE_ROOT=0x2588266e5eaea8ff9867d7a36694e35c04bccc5ab36d40d565d8579beb6aff08 \
NEW_DEPOSIT_SUBTREE_ROOT=0x54deb75cb039b1e82e43dff69194f26d10eae2876fb0aa33c8857a6622fda55c \
NEW_LAST_VERIFIED_WITHDRAWAL_TREE_ROOT=0x030522995310a315f591ff2e948dd628b1fa274e838eeaac00e8ec6a3cba8778 \
NEW_WITHDRAWAL_SUBTREE_ROOT=0x54deb75cb039b1e82e43dff69194f26d10eae2876fb0aa33c8857a6622fda55c \
npx hardhat state-manager:force-set-state --network sepoliaBridge fund rescue after upgrading the in-place implementation:
# ERC20 rescue
DRY_RUN=TimeLock \
RESCUE_MODE=erc20 \
RESCUE_TOKEN=0xToken \
RESCUE_TO=0xRecipient \
RESCUE_AMOUNT=1000000000000000000 \
npx hardhat bridge:rescue --network sepolia
# Native ETH rescue
DRY_RUN=TimeLock \
RESCUE_MODE=native \
RESCUE_TO=0xRecipient \
RESCUE_AMOUNT=1000000000000000000 \
npx hardhat bridge:rescue --network sepolia
# WETH custody unwrap + native rescue
DRY_RUN=TimeLock \
RESCUE_MODE=weth-native \
RESCUE_TO=0xRecipient \
RESCUE_AMOUNT=1000000000000000000 \
npx hardhat bridge:rescue --network sepolia