From f8df1d8275b51830dd6f2257443cf2c3534199a5 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Thu, 4 Dec 2025 10:19:13 -0500 Subject: [PATCH] feat(zkSyncFinalizer): Support post-Gateway migration finalizations We need this code to finalize any withdrawals from ZkStack chains sent through the new `L2AssetRouter` contracts. See https://github.com/across-protocol/contracts/pull/1190 for more details on L2 contract changes that will initiate these new withdrawals This PR is backwards compatible and can be merged today [test finalization can be seen here.](https://etherscan.io/tx/0x88f21e9730a6586155de9f8e46a8f82a78fde09193e3de3d48a783cf94c2dc41#eventlog) --- src/common/ContractAddresses.ts | 5 ++ src/common/abi/ZkStackL1Nullifier.json | 24 +++++ src/finalizer/utils/zkSync.ts | 116 +++++++++++++++++-------- 3 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 src/common/abi/ZkStackL1Nullifier.json diff --git a/src/common/ContractAddresses.ts b/src/common/ContractAddresses.ts index d9da46aac8..7fabee5896 100644 --- a/src/common/ContractAddresses.ts +++ b/src/common/ContractAddresses.ts @@ -21,6 +21,7 @@ import POLYGON_WITHDRAWABLE_ERC20_ABI from "./abi/PolygonWithdrawableErc20.json" import ZKSTACK_NATIVE_TOKEN_VAULT_ABI from "./abi/ZkStackNativeTokenVault.json"; import ZKSTACK_BRIDGE_HUB_ABI from "./abi/ZkStackBridgeHub.json"; import ZKSTACK_SHARED_BRIDGE_ABI from "./abi/ZkStackSharedBridge.json"; +import ZKSTACK_L1_NULLIFIER_ABI from "./abi/ZkStackL1Nullifier.json"; import ZKSTACK_USDC_BRIDGE_ABI from "./abi/ZkStackUSDCBridge.json"; import ARBITRUM_ERC20_GATEWAY_ROUTER_L1_ABI from "./abi/ArbitrumErc20GatewayRouterL1.json"; import ARBITRUM_ERC20_GATEWAY_ROUTER_L2_ABI from "./abi/ArbitrumErc20GatewayRouterL2.json"; @@ -63,6 +64,10 @@ export const CONTRACT_ADDRESSES: { address: "0x8829AD80E425C646DAB305381ff105169FeEcE56", abi: ZKSTACK_SHARED_BRIDGE_ABI, }, + zkStackL1Nullifier: { + address: "0xD7f9f54194C633F36CCD5F3da84ad4a1c38cB2cB", + abi: ZKSTACK_L1_NULLIFIER_ABI, + }, zkStackBridgeHub: { address: "0x303a465B659cBB0ab36eE643eA362c509EEb5213", abi: ZKSTACK_BRIDGE_HUB_ABI, diff --git a/src/common/abi/ZkStackL1Nullifier.json b/src/common/abi/ZkStackL1Nullifier.json new file mode 100644 index 0000000000..ddf7033593 --- /dev/null +++ b/src/common/abi/ZkStackL1Nullifier.json @@ -0,0 +1,24 @@ +[ + { + "inputs": [ + { + "components": [ + { "internalType": "uint256", "name": "chainId", "type": "uint256" }, + { "internalType": "uint256", "name": "l2BatchNumber", "type": "uint256" }, + { "internalType": "uint256", "name": "l2MessageIndex", "type": "uint256" }, + { "internalType": "address", "name": "l2Sender", "type": "address" }, + { "internalType": "uint16", "name": "l2TxNumberInBatch", "type": "uint16" }, + { "internalType": "bytes", "name": "message", "type": "bytes" }, + { "internalType": "bytes32[]", "name": "merkleProof", "type": "bytes32[]" } + ], + "internalType": "struct FinalizeL1DepositParams", + "name": "_finalizeWithdrawalParams", + "type": "tuple" + } + ], + "name": "finalizeDeposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/src/finalizer/utils/zkSync.ts b/src/finalizer/utils/zkSync.ts index 240f0eb804..469a405ba7 100644 --- a/src/finalizer/utils/zkSync.ts +++ b/src/finalizer/utils/zkSync.ts @@ -21,6 +21,7 @@ import { EvmAddress, Address, Provider, + getNetworkName, } from "../../utils"; import { FinalizerPromise, CrossChainMessage } from "../types"; @@ -39,6 +40,9 @@ const IGNORED_WITHDRAWALS = [ "0xe93642e22eec21ead2abb20f23a1dc3033b41274cdfe7439cf3ada3dfa1dff06", // Lens USDC 2025-06-13 @todo remove ]; +// This is the system address of the L2 Asset Router for all ZkStack chains. +const L2ASSETROUTER_ADDRESS = "0x0000000000000000000000000000000000010003"; + /** * @returns Withdrawal finalization calldata and metadata. */ @@ -51,6 +55,7 @@ export async function zkSyncFinalizer( assert(isEVMSpokePoolClient(spokePoolClient)); const { chainId: l1ChainId } = hubPoolClient; const { chainId: l2ChainId } = spokePoolClient; + const networkName = getNetworkName(l2ChainId); const l1Provider = hubPoolClient.hubPool.provider; const l2Provider = zkSyncUtils.convertEthersRPCToZKSyncRPC(spokePoolClient.spokePool.provider); @@ -69,8 +74,8 @@ export async function zkSyncFinalizer( ); logger.debug({ - at: "Finalizer#ZkSyncFinalizer", - message: "ZkSync TokensBridged event filter", + at: `Finalizer#${networkName}Finalizer`, + message: `${networkName} TokensBridged event filter`, toBlock: latestBlockToFinalize, }); const withdrawalsToQuery = spokePoolClient @@ -102,8 +107,8 @@ export async function zkSyncFinalizer( // - processing/committed: Pending finalization // - finalized: ready to be withdrawn or already withdrawn logger.debug({ - at: "ZkSyncFinalizer", - message: "ZkSync withdrawal status.", + at: `${networkName}Finalizer`, + message: `${networkName} withdrawal status.`, statusesGrouped: { withdrawalNotFound: statuses["not-found"]?.length, withdrawalProcessing: statuses["processing"]?.length, @@ -167,7 +172,7 @@ async function filterMessageLogs( const l1UsdcBridge = getSharedBridge(l1ChainId, chainId, l2TokenAddress, wallet._providerL1()); return !(await l1UsdcBridge.isWithdrawalFinalized(chainId, l1BatchNumber, id)); } - return !(await wallet.isWithdrawalFinalized(txnRef, withdrawalIdx)); + return !(await isWithdrawalFinalized(wallet, txnRef, withdrawalIdx)); } catch (error: unknown) { if (error instanceof Error && error.message.includes("Log proof not found")) { return false; @@ -179,6 +184,33 @@ async function filterMessageLogs( return ready; } +/** + * Returns whether the withdrawal transaction is finalized on the L1 network. + * @dev Copied from https://github.com/zksync-sdk/zksync-ethers/blob/v5.11.0/src/adapters.ts#L1504 and modified + * to work with new L2AssetRouter contract, which zksync-ethers >6.X handles but is only compatible with ethers >6.X + * @param withdrawalHash Hash of the L2 transaction where the withdrawal was initiated. + * @param [withdrawalIndex=0] In case there were multiple withdrawals in one transaction, you may pass an index of the + * withdrawal you want to finalize. + * @throws {Error} If log proof can not be found. + */ +async function isWithdrawalFinalized(wallet: zkWallet, withdrawalHash: string, withdrawalIndex = 0): Promise { + const { log } = await wallet._getWithdrawalLog(withdrawalHash, withdrawalIndex); + const { l2ToL1LogIndex } = await wallet._getWithdrawalL2ToL1Log(withdrawalHash, withdrawalIndex); + // `getLogProof` is called not to get proof but + // to get the index of the corresponding L2->L1 log, + // which is returned as `proof.id`. + const proof = await wallet._providerL2().getLogProof(withdrawalHash, l2ToL1LogIndex); + if (!proof) { + throw new Error("Log proof not found!"); + } + + const chainId = (await wallet._providerL2().getNetwork()).chainId; + + const l1Bridge = (await wallet.getL1BridgeContracts()).shared; + + return await l1Bridge.isWithdrawalFinalized(chainId, log.l1BatchNumber!, proof.id); +} + function withdrawalRequiresCustomUsdcBridge(l1ChainId: number, l2ChainId: number, l2TokenAddress: Address): boolean { if (CONTRACT_ADDRESSES[l1ChainId]?.[`zkStackUSDCBridge_${l2ChainId}`] && CONTRACT_ADDRESSES[l2ChainId]?.usdcBridge) { const l2Usdc = EvmAddress.from(TOKEN_SYMBOLS_MAP.USDC.addresses[l2ChainId]); @@ -200,34 +232,6 @@ async function getWithdrawalParams( async ({ txnRef, withdrawalIdx }) => await wallet.finalizeWithdrawalParams(txnRef, withdrawalIdx) ); } - -/** - * @param withdrawal Withdrawal proof data for a single withdrawal. - * @param ethAddr Ethereum address on the L2. - * @param l1Mailbox zkSync mailbox contract on the L1. - * @param l1ERC20Bridge zkSync ERC20 bridge contract on the L1. - * @returns Calldata for a withdrawal finalization. - */ -async function prepareFinalization( - withdrawal: zkSyncWithdrawalData, - l2ChainId: number, - l1SharedBridge: Contract -): Promise { - const args = [ - l2ChainId, - withdrawal.l1BatchNumber, - withdrawal.l2MessageIndex, - withdrawal.l2TxNumberInBlock, - withdrawal.message, - withdrawal.proof, - ]; - - // @todo Support withdrawing directly as WETH here. - const [target, txn] = [l1SharedBridge.address, await l1SharedBridge.populateTransaction.finalizeWithdrawal(...args)]; - - return { target, callData: txn.data }; -} - /** * @param l1ChainId Chain ID for the L1. * @param l2ChainId Chain ID for the L2. @@ -246,8 +250,42 @@ async function prepareFinalizations( ); return await sdkUtils.mapAsync(withdrawalParams, async (withdrawal, idx) => { - const sharedBridge = getSharedBridge(l1ChainId, tokensBridged[idx].chainId, tokensBridged[idx].l2TokenAddress); - return prepareFinalization(withdrawal, l2ChainId, sharedBridge); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let args: any[], target: string, callData: string; + // If withdrawal originated from new L2AssetRouter contract, we need to call the new finalizeDeposit function + // on the L1Nullifier contract. + if (withdrawal.sender === L2ASSETROUTER_ADDRESS) { + args = [ + l2ChainId, + withdrawal.l1BatchNumber, + withdrawal.l2MessageIndex, + withdrawal.sender, + withdrawal.l2TxNumberInBlock, + withdrawal.message, + withdrawal.proof, + ]; + const l1Nullifier = getL1Nullifier(l1ChainId); + target = l1Nullifier.address; + const populatedTransaction = await l1Nullifier.populateTransaction.finalizeDeposit(args); + callData = populatedTransaction.data; + } else { + args = [ + l2ChainId, + withdrawal.l1BatchNumber, + withdrawal.l2MessageIndex, + withdrawal.l2TxNumberInBlock, + withdrawal.message, + withdrawal.proof, + ]; + const l1SharedBridge = getSharedBridge(l1ChainId, tokensBridged[idx].chainId, tokensBridged[idx].l2TokenAddress); + target = l1SharedBridge.address; + const populatedTransaction = await l1SharedBridge.populateTransaction.finalizeWithdrawal(...args); + callData = populatedTransaction.data; + } + return { + target, + callData, + }; }); } @@ -268,3 +306,11 @@ function getSharedBridge( } return new Contract(contract.address, contract.abi, l1Provider); } + +function getL1Nullifier(l1ChainId: number, l1Provider?: Provider): Contract { + const contract = CONTRACT_ADDRESSES[l1ChainId]?.zkStackL1Nullifier; + if (!contract) { + throw new Error(`zkStack L1 nullifier contract data not found for chain ${l1ChainId}`); + } + return new Contract(contract.address, contract.abi, l1Provider); +}