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 StateManager.finalizewith a zero withdrawal root stays open for empty catch-up finalizes until a non-zero withdrawal root has been finalized (WithdrawalBootstrapExpiredafter that). The empty proof is the all-zero leaf and siblings. Any other proof is checked with the existing top-tree verifier and revertsInvalidWithdrawalMerkleProofwhen it does not hash to the zero root.
PsyToken.claimReward is the reward entry. It reverts unless block.chainid is 1, 31337, or 11155111 (WrongChain), before any mint or claimed-state write. A valid claim mints totalAmount of PSY (9 decimals) to recipient and sets claimed[userId][checkpointId] for each strictly increasing checkpoint id (at most 64). The same pair cannot be claimed again (AlreadyClaimed). Owner mint is separate and is not a claim.
The checkpoint tree root must have a nonzero StateManager.checkpointTreeRootCheckpointId within the finalized tip, and checkpointTreeRootsClean() must be true (UnrecognizedRewardRoot otherwise). The mapping is the single source of truth; there is no root array or count. Ordinary finalization preserves historical roots. A real force-set, including same-height replacement or an empty registry, pauses finalization and claims. Administrators revoke named abandoned roots and explicitly call setCheckpointTreeRootsClean(true) after cleanup; unaffected historical roots remain registered. Later alternate-branch advancement cannot restore revoked roots; only a new proof finalization can register them again. Claimed pairs never reset. The proof is 256 bytes decoded as uint256[8] and checked by the address stored at REWARD_CLAIM_VERIFIER_ID. The verifier input is the high and low 128 bits of keccak256(abi.encode(commitment)), where commitment = keccak256(abi.encode(CLAIM_DOMAIN, token, stateManager, userId, recipient, checkpointRoot, count, totalAmount, paddedIds)), CLAIM_DOMAIN = keccak256("PsyReward/TokenCheckpointClaim/1"), and unused id slots are zero.
No real Groth16 verifier is deployed for this path. forge test -C . --match-contract PsyTokenClaimTest is a component test: RewardClaimDigestVerifier accepts one explicit 8-word test proof for the digest PsyToken computed. It is not an end-to-end payout and not real-proof evidence.
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
Bridge.batchClaimWithdrawal handles up to 32 proven withdrawal slots. Small withdrawals within the token lifetime cap pay directly and emit WithdrawalClaimed, without pending state. Medium withdrawals create pendingWithdrawals[nonce] with claimableAt and emit WithdrawalPendingCreated; after that deadline, claimPendingWithdrawal pays the original recipient. Large withdrawals or withdrawals whose next cumulative total exceeds totalWithdrawalCap require the governance withdrawalForceClaimExecutor to call forceClaimWithdrawal after their stored deadline. Governance cannot bypass the delay or redirect funds. Both paths consume claimedNullifiers[nonce] and add to the lifetime token total before transfer; settlement never decrements that total. withdrawalRequiresGovernance(nonce) reports the stored permission. Pre-upgrade pending entries without a tier require governance while preserving their deadline. Local medium/large delays are one hour/seven days; non-local deployments require reviewed manifests.
These failures revert the whole transaction with zero effects — no nullifier written, no total changed, no event emitted:
| Condition | Error |
|---|---|
withdrawalClaimVerifier == address(0) |
VerifierNotSet |
| Groth16 verification fails | the verifier's own revert |
realCount == 0 or realCount > 32 |
InvalidRealCount |
proof root is neither withdrawalSubtreeRoot() nor in knownWithdrawalSubtreeRoots |
InvalidWithdrawalProof |
bridgeUserId != BRIDGE_USER_ID() |
InvalidPublicInputs |
slot-data commit differs from the proof's batchCommit |
InvalidWithdrawalProof |
any padding slot (index >= realCount) is nonzero |
InvalidPublicInputs |
The commit and padding checks run before any state change, so slot data the proof never committed to can neither register nor emit. Anything not in the reason table below also reverts the whole transaction; an unexpected internal failure is never swallowed per slot.
A failure inside the table skips only that slot: the nullifier stays unconsumed, the total is untouched, and the other slots still register. The function returns two parallel arrays holding the failed slots only, compacted and in ascending slot order:
failedSlotIndexes— slot positions0..realCount-1failureReasons— theCLAIM_REASON_*code for each
A mined transaction carries no return data. The receipt is the record, and it always ends with one WithdrawalClaimBatchResult(batchCommit, realCount, succeededCount, failedCount) where succeededCount + failedCount == realCount. Each failed slot also emits WithdrawalClaimItemFailed(nonce, slotIndex, reason). slotIndex is the second indexed topic; reason is the non-indexed data word. The same nonce can appear in both a WithdrawalPendingCreated and a WithdrawalClaimItemFailed when two slots share it, and only slotIndex tells them apart.
0 means the slot was valid. 12..255 are reserved and never emitted. The eleven codes, exactly as named in src/Bridge.sol:
| Code | Name | Meaning |
|---|---|---|
| 1 | CLAIM_REASON_ADDRESS_HIGH_BITS |
recipient or token word has bits set above bit 160 |
| 2 | CLAIM_REASON_WRONG_DESTINATION_CHAIN |
slot destination chain is not StateManager.l1ChainIndex() |
| 3 | CLAIM_REASON_ZERO_ADDRESS |
recipient is the zero address |
| 4 | CLAIM_REASON_ZERO_AMOUNT |
amount is zero |
| 5 | CLAIM_REASON_NULLIFIER_ALREADY_CLAIMED |
claimedNullifiers[nonce] is already set |
| 6 | CLAIM_REASON_TOKEN_NOT_CONFIGURED |
the token has no flow config |
| 7 | CLAIM_REASON_REGISTRATION_PAUSED |
effective pause flags include withdrawal-registration pause |
| 8 | CLAIM_REASON_AMOUNT_OUT_OF_RANGE |
amount is zero |
| 9 | CLAIM_REASON_TOTAL_AMOUNT_OVERFLOW |
amount + totalWithdrawalAmount(token) would overflow uint256 |
| 10 | CLAIM_REASON_CLAIMABLE_AT_OVERFLOW |
block.timestamp + delay would not fit in uint64 |
| 11 | CLAIM_REASON_TOKEN_MAPPING_MISMATCH |
the Router does not dual-bind the slot's token: l2ToL1Token(l1ToL2Token(token)) is not the token |
Checked in that order, so the first match wins: a zero amount whose address also has high bits reports 1, not 4. Code 11 comes from the Router dual read between the nullifier check and _withdrawalRegistrationCheck; the withdrawal slot carries no burning L2 contract id, so the forward read supplies it and the reverse read must resolve back to the token (bytes32(0) is a real L2 id, which makes the reverse read the check). Codes 6–10 come from _withdrawalRegistrationCheck, which also selects the delay tier (small, medium, or large; the large tier also applies when the next total would pass totalWithdrawalCap).
Reason 5 covers both a nonce consumed by an earlier transaction and a duplicate of a slot earlier in the same batch. The first slot registers and emits WithdrawalPendingCreated; every later slot with that nonce fails with reason 5 and emits WithdrawalClaimItemFailed for its own slot index. Nothing about the first registration changes.
Reason 5 says only that the nonce is consumed. Nonces are not unique across withdrawals, so a consumed nonce is this withdrawal's only when on-chain evidence shows the nonce carries this withdrawal's tuple (token, recipient, amount): the still-pending pendingWithdrawals(nonce) entry, or, once that entry has settled away, the historical WithdrawalPendingCreated for that nonce. WithdrawalClaimed is not an identity source — it records the payout, not the registration, and the pending entry is deleted on settlement. A consumed nonce whose tuple differs, or whose registration cannot be found, is not an already-claimed success.
claimPendingWithdrawal reverts PendingWithdrawalNotFound when no pending entry exists (amount == 0, which also covers an already settled nonce), PendingClaimsPaused when the effective flags include pending-claim pause, and PendingWithdrawalNotClaimable before claimableAt. forceClaimWithdrawal also enforces the time check and reverts UnauthorizedWithdrawalForceClaimExecutor for any other caller. Ordinary claims additionally reject governed entries with WithdrawalGovernanceRequired. Neither call consumes a nullifier; registration already did. A registration that was skipped, including reasons 6, 7, 9, and 10, leaves the nullifier free, so the same withdrawal can be registered again once the condition clears. Pause flags are PAUSE_DEPOSITS (bit 0), PAUSE_WITHDRAWAL_REGISTRATION (bit 1), and PAUSE_PENDING_CLAIMS (bit 2), combined as global | per-token.
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 a storage-compatible Router upgrade
DRY_RUN=TimeLock npx hardhat upgrade --contract Router --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 localhostStateManager in-place upgrades are blocked by the shared upgrade helper until a reviewed migration exists. This includes upgrade --contract StateManager, upgrade:state-manager, and upgrade:all; the all-contract helper preflights the entire set before any implementation deployment or upgrade. Do not bypass this guard with raw proxy-admin calldata.
StateManager force state repair uses named checkpoint-tree-root revocation and an independent clean flag:
Deployment boundary: removing the parallel root array changes the clean flag's storage slot. Do not upgrade a populated deployment with the former array layout in place: its array length could be interpreted as the clean flag. Rebuild/redeploy the local StateManager and non-upgradeable PsyToken before E2E, wire the provider consistently, and regenerate guardian authorization after deployment because it binds L1 code hashes. Production migration requires a separately reviewed storage and registry migration; do not reuse authorization from the preceding deployment.
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 sepoliaAfter the force-set transaction executes, checkpointTreeRootsClean() is false. Identify abandoned roots from retained settlement evidence (Finalized events and the approved rollback lineage); the contract does not enumerate them. Preserve unaffected historical roots. Encode/execute each named batch with StateManager-admin authority:
DRY_RUN=TimeLock npx hardhat state-manager:revoke-checkpoint-tree-roots --roots-json abandoned-checkpoint-roots.json --network sepolia--roots-json accepts a file path or an inline JSON array of nonzero bytes32 hex values. revokeCheckpointTreeRoots(bytes32[]) zeroes only the named mapping entries, accepts repeated/already-revoked roots, and never changes the clean flag. Split the approved list into batches that fit the network gas limit. If bridge subtree memberships are abandoned, separately encode revokeKnownSubtreeRoots(bytes32[],bytes32[]) with the deposit and withdrawal subtree lists; it sets only those known-root entries false, is idempotent, and does not change the clean flag or current state fields.
DRY_RUN=TimeLock npx hardhat state-manager:revoke-known-subtree-roots --deposit-roots-json abandoned-deposit-roots.json --withdrawal-roots-json abandoned-withdrawal-roots.json --network sepoliaBoth subtree options accept JSON files or inline arrays, including empty arrays and the zero root. Withdrawal warning: clearing knownWithdrawalSubtreeRoots[root] does not revoke the current withdrawalSubtreeRoot field: Bridge accepts equality with that current root independently of the mapping (src/Bridge.sol:719-724). Abandoning the current withdrawal root requires repairing the state field as well as revoking its historical membership. knownDepositSubtreeRoots currently has no production reader; it is audit state, not an enforced deposit-acceptance gate.
Queue and execute all operations through the existing timelock/Safe workflow (SafeWithTimeLock when the Safe owns timelock administration); use a distinct --execution-time for repeated identical timelock operations. Verify every named checkpoint root now maps to zero, every revoked subtree membership is false, and retained roots are unchanged. Only after the complete cleanup has been verified, explicitly re-enable claims and finalization:
DRY_RUN=TimeLock npx hardhat state-manager:set-checkpoint-tree-roots-clean --clean true --network sepoliaThe clean flag attests only to the operator's named list, not its completeness or ancestry; it is not a computed array-length check. setCheckpointTreeRootsClean(false) can pause claims and finalization independently. An empty registry still requires explicit re-enablement after a real force-set. A no-op force-set neither starts nor completes cleanup; force-set targets are never registered as checkpoint tree proofs.
Bridge 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