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 password- RPC for the selected network:
LOCALHOST_RPC_URL,LOCALHOST_BSC_RPC_URL,LOCALHOST_BASE_RPC_URL,SEPOLIA_RPC_URL,BSC_TESTNET_RPC_URL, orBASE_SEPOLIA_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:localhostKEYSTORE_PATH=... WALLET_PASSWORD=... LOCALHOST_BSC_RPC_URL=http://127.0.0.1:9545 npm run deploy:keystore:localhost-bscKEYSTORE_PATH=... WALLET_PASSWORD=... LOCALHOST_BASE_RPC_URL=http://127.0.0.1:10545 npm run deploy:keystore:localhost-base
KEYSTORE_PATH=... WALLET_PASSWORD=... SEPOLIA_RPC_URL=https://... npm run deploy:keystore:sepoliaKEYSTORE_PATH=... WALLET_PASSWORD=... BSC_TESTNET_RPC_URL=https://... npm run deploy:keystore:bsc-testnetKEYSTORE_PATH=... WALLET_PASSWORD=... BASE_SEPOLIA_RPC_URL=https://... npm run deploy:keystore:base-sepolia
Every deploy fails closed when the RPC eth_chainId differs from
protocol-config. The final deployment export also verifies the on-chain
StateManager.l1ChainIndex() before writing deployed-contracts.json.
New USDTToken deployments on Ethereum Sepolia, BSC Testnet, and Base Sepolia
use Psy USDT for ERC-20 name() and pUSDT for symbol() (lowercase p,
uppercase USDT). Local deployments use the same contract. This is Psy testnet USDT
for bridge testing, not an official Tether token and not redeemable for USD.
Decimals remain 6 and the deployment script still mints 1 billion tokens.
USDTToken, the protocol key USDT, L2 token ID 4, and the deployment JSON
keys remain unchanged for compatibility. This change only renames the on-chain
test token; it does not update DApp or wallet display labels. The configured
Ethereum mainnet USDT address is unchanged.
Existing deployed tokens cannot be renamed: they have no metadata setter or upgrade proxy. Do not overwrite existing deployment addresses to apply this change. A fresh deployment or separately reviewed migration must account for Router mappings, Faucet configuration, outstanding claims, and old balances.
After an authorized deployment, verify name(), symbol(), and decimals()
on each chain. Provide the chain ID, new token address, verified source code,
and faucet address to the wallet security team for review. Renaming does not
automatically remove an existing Blockaid or MetaMask warning.
StateManager.bridge == BridgeStateManager.proposer == expected proposerRouter.bridge == BridgeRouter.defaultERC20Gateway == ERC20GatewayRouter.ethGateway == ETHGatewayStateManager.zkVerifier != address(0)- deployment
chainIdequals RPCeth_chainId - deployment
protocol.chain.l1ChainIndexequalsStateManager.l1ChainIndex()
StateManager.appendDeposit: only BridgeStateManager.finalize: only ProposerBridge.recordDeposit: disabled; the supported 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.tsgrantsDEFAULT_ADMIN_ROLE,BRIDGE_ADMIN_ROLE,ROUTER_ADMIN_ROLE, andSTATE_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. PROPOSER_ROLEis an operational bot role, intentionally separate from governance: it is held by a dedicated proposer address that must be distinct from the deployer/admin, the Governance Safe, and the timelock. It is not migrated or revoked during the governance cutover and survives cutovers by design.
Notes:
- Deploying
ExecutorWithTimelockalone does not hand over every permission. By default,cfg.adminremains the ACL default admin and theDefaultProxyAdminowner. GRANT_TIMELOCK_ROLES=1grants ACL administration, Bridge administration, Router administration, and StateManager administration to the timelock. It does not transfer proxy-upgrade ownership; that remains a separate cutover.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-stateandbridge:force-set-staterequire completeEXPECTED_*andNEW_*non-mapping state tuples; inputs are validated before encoding or sending a transaction. 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. - Mainnet cutover gate: a mainnet deployment is only considered complete when all of the following hold:
GRANT_TIMELOCK_ROLES=1(ACL admin roles to the timelock),TRANSFER_PROXY_ADMIN_TO_TIMELOCK=1(proxy-admin ownership to the timelock; the deploy script now refuses to silently skip on non-local networks), andnpx hardhat governance:verify-permissions --strip-admins <admin addresses> --proposer <bot>passes with no violations. The verifier enforces that no stripped account holds BRIDGE_ADMIN/ROUTER_ADMIN/STATE_MANAGER_ADMIN/DEFAULT_ADMIN/GUARDIAN (the Governance Safe keeps GUARDIAN by design) and that the proposer is a configured bot address distinct from the timelock and the Governance Safe.
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 \
EXPECTED_LAST_FINALIZED_CHECKPOINT_ID=100187 \
EXPECTED_LAST_VERIFIED_CHECKPOINT_ROOT=0xe3f1bcc23eff84f7a1d2f71c91cfdcc5cd3947380970cbd49fe8663eb78e2b0a \
EXPECTED_LAST_VERIFIED_DEPOSIT_TREE_ROOT=0x2588266e5eaea8ff9867d7a36694e35c04bccc5ab36d40d565d8579beb6aff08 \
EXPECTED_LAST_VERIFIED_WITHDRAWAL_TREE_ROOT=0x030522995310a315f591ff2e948dd628b1fa274e838eeaac00e8ec6a3cba8778 \
EXPECTED_WITHDRAWAL_SUBTREE_ROOT=0x54deb75cb039b1e82e43dff69194f26d10eae2876fb0aa33c8857a6622fda55c \
NEW_LAST_FINALIZED_CHECKPOINT_ID=100000 \
NEW_LAST_VERIFIED_CHECKPOINT_ROOT=0xe3f1bcc23eff84f7a1d2f71c91cfdcc5cd3947380970cbd49fe8663eb78e2b0a \
NEW_LAST_VERIFIED_DEPOSIT_TREE_ROOT=0x2588266e5eaea8ff9867d7a36694e35c04bccc5ab36d40d565d8579beb6aff08 \
NEW_LAST_VERIFIED_WITHDRAWAL_TREE_ROOT=0x030522995310a315f591ff2e948dd628b1fa274e838eeaac00e8ec6a3cba8778 \
NEW_WITHDRAWAL_SUBTREE_ROOT=0x54deb75cb039b1e82e43dff69194f26d10eae2876fb0aa33c8857a6622fda55c \
npx hardhat state-manager:force-set-state --network sepoliaBridge force state repair uses EXPECTED_BRIDGE_* and NEW_BRIDGE_* values for the deposit root, proved/pending counts, and DEPOSIT_FRONTIER_JSON. Each frontier must be a JSON array containing exactly 32 bytes32 hex values:
DRY_RUN=TimeLock \
EXPECTED_BRIDGE_DEPOSIT_ROOT=0x... \
EXPECTED_BRIDGE_PROVED_DEPOSIT_COUNT=100 \
EXPECTED_BRIDGE_PENDING_DEPOSIT_COUNT=120 \
EXPECTED_BRIDGE_DEPOSIT_FRONTIER_JSON='["0x...", "..."]' \
NEW_BRIDGE_DEPOSIT_ROOT=0x... \
NEW_BRIDGE_PROVED_DEPOSIT_COUNT=90 \
NEW_BRIDGE_PENDING_DEPOSIT_COUNT=110 \
NEW_BRIDGE_DEPOSIT_FRONTIER_JSON='["0x...", "..."]' \
npx hardhat bridge: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