Skip to content

Commit

Permalink
feat: Add zkSync finalizer (#890)
Browse files Browse the repository at this point in the history
zkSync withdrawals are currently auto-finalized on some periodic
schedule, but this finalizer will probably be able to front run many of
those, and will be available for when zkSync stop auto-finalizing.

Closes ACX-1332
  • Loading branch information
pxrl authored Aug 22, 2023
1 parent 3e83ce2 commit d205549
Show file tree
Hide file tree
Showing 4 changed files with 287 additions and 0 deletions.
46 changes: 46 additions & 0 deletions src/common/ContractAddresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ export const CONTRACT_ADDRESSES: {
stateMutability: "pure",
type: "function",
},
{
inputs: [
{ internalType: "uint256", name: "_l2BlockNumber", type: "uint256" },
{ internalType: "uint256", name: "_l2MessageIndex", type: "uint256" },
{ internalType: "uint16", name: "_l2TxNumberInBlock", type: "uint16" },
{ internalType: "bytes", name: "_message", type: "bytes" },
{ internalType: "bytes32[]", name: "_merkleProof", type: "bytes32[]" },
],
name: "finalizeEthWithdrawal",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [
{ internalType: "uint256", name: "_l2BlockNumber", type: "uint256" },
{ internalType: "uint256", name: "_l2MessageIndex", type: "uint256" },
],
name: "isEthWithdrawalFinalized",
outputs: [{ internalType: "bool", name: "", type: "bool" }],
stateMutability: "view",
type: "function",
},
],
},
zkSyncDefaultErc20Bridge: {
Expand All @@ -55,6 +78,29 @@ export const CONTRACT_ADDRESSES: {
stateMutability: "payable",
type: "function",
},
{
inputs: [
{ internalType: "uint256", name: "_l2BlockNumber", type: "uint256" },
{ internalType: "uint256", name: "_l2MessageIndex", type: "uint256" },
{ internalType: "uint16", name: "_l2TxNumberInBlock", type: "uint16" },
{ internalType: "bytes", name: "_message", type: "bytes" },
{ internalType: "bytes32[]", name: "_merkleProof", type: "bytes32[]" },
],
name: "finalizeWithdrawal",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [
{ internalType: "uint256", name: "_l2BlockNumber", type: "uint256" },
{ internalType: "uint256", name: "_l2MessageIndex", type: "uint256" },
],
name: "isWithdrawalFinalized",
outputs: [{ internalType: "bool", name: "", type: "bool" }],
stateMutability: "view",
type: "function",
},
{
anonymous: false,
inputs: [
Expand Down
5 changes: 5 additions & 0 deletions src/finalizer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
multicallArbitrumFinalizations,
multicallOptimismL1Proofs,
isOVMChainId,
zkSyncFinalizer,
} from "./utils";
import { SpokePoolClientsByChain } from "../interfaces";
import { HubPoolClient, SpokePoolClient } from "../clients";
Expand All @@ -45,6 +46,8 @@ const oneDaySeconds = 24 * 60 * 60;
const chainFinalizers: { [chainId: number]: ChainFinalizer } = {
10: opStackFinalizer,
137: polygonFinalizer,
280: zkSyncFinalizer,
324: zkSyncFinalizer,
8453: opStackFinalizer,
42161: arbitrumOneFinalizer,
};
Expand Down Expand Up @@ -165,6 +168,8 @@ export async function finalize(
const finalizationWindows: { [chainId: number]: number } = {
10: optimisticRollupFinalizationWindow,
137: polygonFinalizationWindow,
280: oneDaySeconds * 8,
324: oneDaySeconds * 4,
8453: optimisticRollupFinalizationWindow,
42161: optimisticRollupFinalizationWindow,
};
Expand Down
1 change: 1 addition & 0 deletions src/finalizer/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./polygon";
export * from "./arbitrum";
export * from "./opStack";
export * from "./zkSync";
235 changes: 235 additions & 0 deletions src/finalizer/utils/zkSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { Contract, ethers, Wallet } from "ethers";
import { Provider as zksProvider, types as zkTypes, utils as zkUtils, Wallet as zkWallet } from "zksync-web3";
import { groupBy } from "lodash";
import { interfaces, utils as sdkUtils } from "@across-protocol/sdk-v2";
import { HubPoolClient, SpokePoolClient } from "../../clients";
import { CONTRACT_ADDRESSES, Multicall2Call } from "../../common";
import { convertFromWei, getEthAddressForChain, winston } from "../../utils";
import { zkSync as zkSyncUtils } from "../../utils";
import { FinalizerPromise, Withdrawal } from "../types";

type Provider = ethers.providers.Provider;
type TokensBridged = interfaces.TokensBridged;

type zkSyncWithdrawalData = {
l1BatchNumber: number;
l2MessageIndex: number;
l2TxNumberInBlock: number;
message: string;
sender: string;
proof: string[];
};

const TransactionStatus = zkTypes.TransactionStatus;

/**
* @returns Withdrawal finalizaton calldata and metadata.
*/
export async function zkSyncFinalizer(
logger: winston.Logger,
signer: Wallet,
hubPoolClient: HubPoolClient,
spokePoolClient: SpokePoolClient,
oldestBlockToFinalize: number
): Promise<FinalizerPromise> {
const { chainId: l1ChainId } = hubPoolClient;
const { chainId: l2ChainId } = spokePoolClient;

const l1Provider = hubPoolClient.hubPool.provider;
const l2Provider = zkSyncUtils.convertEthersRPCToZKSyncRPC(spokePoolClient.spokePool.provider);
const wallet = new zkWallet(signer.privateKey, l2Provider, l1Provider);

// Any block younger than latestBlockToFinalize is ignored.
const withdrawalsToQuery = spokePoolClient
.getTokensBridged()
.filter(({ blockNumber }) => blockNumber > oldestBlockToFinalize);
const { committed: l2Committed, finalized: l2Finalized } = await sortWithdrawals(l2Provider, withdrawalsToQuery);
const candidates = await filterMessageLogs(wallet, l2Provider, l2Finalized);
const withdrawalParams = await getWithdrawalParams(wallet, candidates);
const txns = await prepareFinalizations(l1ChainId, l2ChainId, withdrawalParams);

const withdrawals = candidates.map(({ l2TokenAddress, amountToReturn }) => {
const l1TokenCounterpart = hubPoolClient.getL1TokenCounterpartAtBlock(
l2ChainId,
l2TokenAddress,
hubPoolClient.latestBlockNumber
);
const { decimals, symbol: l1TokenSymbol } = hubPoolClient.getTokenInfo(l1ChainId, l1TokenCounterpart);
const amountFromWei = convertFromWei(amountToReturn.toString(), decimals);
const withdrawal: Withdrawal = {
l2ChainId,
l1TokenSymbol,
amount: amountFromWei,
type: "withdrawal",
};

return withdrawal;
});

logger.debug({
at: "zkSyncFinalizer",
message: "zkSync withdrawal status.",
statusesGrouped: {
withdrawalPending: withdrawalsToQuery.length - l2Finalized.length,
withdrawalReady: candidates.length,
withdrawalFinalized: l2Finalized.length - candidates.length,
},
committed: l2Committed,
});

return { callData: txns, withdrawals };
}

/**
* @dev For L2 transactions, status "finalized" is required before any contained messages can be executed on the L1.
* @param provider zkSync L2 provider instance (must be of type zksync-web3.Provider).
* @param tokensBridged Array of TokensBridged events to evaluate for finalization.
* @returns TokensBridged events sorted according to pending and ready for finalization.
*/
async function sortWithdrawals(
provider: zksProvider,
tokensBridged: TokensBridged[]
): Promise<{ committed: TokensBridged[]; finalized: TokensBridged[] }> {
const txnStatus = await Promise.all(
tokensBridged.map(({ transactionHash }) => provider.getTransactionStatus(transactionHash))
);

let idx = 0; // @dev Possible to infer the loop index in groupBy ??
const { committed = [], finalized = [] } = groupBy(tokensBridged, () =>
txnStatus[idx++] === TransactionStatus.Finalized ? "finalized" : "committed"
);

return { committed, finalized };
}

/**
* @param wallet zkSync wallet instance.
* @param l2Provider L2 provider instance.
* @param tokensBridged Array of TokensBridged events to evaluate for finalization.
* @returns TokensBridged events sorted according to pending and ready for finalization.
*/
async function filterMessageLogs(
wallet: zkWallet,
l2Provider: Provider,
tokensBridged: TokensBridged[]
): Promise<(TokensBridged & { withdrawalIdx: number })[]> {
const l1MessageSent = zkUtils.L1_MESSENGER.getEventTopic("L1MessageSent");

// Filter transaction hashes for duplicates, then request receipts for each hash.
const txnHashes = [...new Set(tokensBridged.map(({ transactionHash }) => transactionHash))];
const txnReceipts = Object.fromEntries(
await sdkUtils.mapAsync(txnHashes, async (txnHash) => [txnHash, await l2Provider.getTransactionReceipt(txnHash)])
);

// Extract the relevant L1MessageSent events from the transaction.
const withdrawals = tokensBridged.map((tokenBridged) => {
const { transactionHash, logIndex } = tokenBridged;
const txnReceipt = txnReceipts[transactionHash];

// Search backwards from the TokensBridged log index for the corresponding L1MessageSent event.
// @dev Array.findLast() would be an improvement but tsc doesn't currently allow it.
const txnLogs = txnReceipt.logs.slice(0, logIndex).reverse();
const withdrawal = txnLogs.find((log) => log.topics[0] === l1MessageSent);

// @dev withdrawalIdx is the "withdrawal number" within the transaction, _not_ the index of the log.
const l1MessagesSent = txnReceipt.logs.filter((log) => log.topics[0] === l1MessageSent);
const withdrawalIdx = l1MessagesSent.indexOf(withdrawal);
return { ...tokenBridged, withdrawalIdx };
});

const ready = await sdkUtils.filterAsync(
withdrawals,
async ({ transactionHash, withdrawalIdx }) => !(await wallet.isWithdrawalFinalized(transactionHash, withdrawalIdx))
);

return ready;
}

/**
* @param wallet zkSync wallet instance.
* @param msgLogs Array of transactionHash and withdrawal index pairs.
* @returns Withdrawal proof data for each withdrawal.
*/
async function getWithdrawalParams(
wallet: zkWallet,
msgLogs: { transactionHash: string; withdrawalIdx: number }[]
): Promise<zkSyncWithdrawalData[]> {
return await sdkUtils.mapAsync(
msgLogs,
async ({ transactionHash, withdrawalIdx }) => await wallet.finalizeWithdrawalParams(transactionHash, 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,
ethAddr: string,
l1Mailbox: Contract,
l1ERC20Bridge: Contract
): Promise<Multicall2Call> {
const args = [
withdrawal.l1BatchNumber,
withdrawal.l2MessageIndex,
withdrawal.l2TxNumberInBlock,
withdrawal.message,
withdrawal.proof,
];

// @todo Support withdrawing directly as WETH here.
const [target, txn] =
withdrawal.sender.toLowerCase() === ethAddr.toLowerCase()
? [l1Mailbox.address, await l1Mailbox.populateTransaction.finalizeEthWithdrawal(...args)]
: [l1ERC20Bridge.address, await l1ERC20Bridge.populateTransaction.finalizeWithdrawal(...args)];

return { target, callData: txn.data };
}

/**
* @param l1ChainId Chain ID for the L1.
* @param l2ChainId Chain ID for the L2.
* @param withdrawalParams Array of proof data for each withdrawal to finalize.
* @returns Array of calldata for each input withdrawal to finalize.
*/
async function prepareFinalizations(
l1ChainId: number,
l2ChainId: number,
withdrawalParams: zkSyncWithdrawalData[]
): Promise<Multicall2Call[]> {
const l1Mailbox = getMailbox(l1ChainId);
const l1ERC20Bridge = getL1ERC20Bridge(l1ChainId);
const ethAddr = getEthAddressForChain(l2ChainId);

return await sdkUtils.mapAsync(withdrawalParams, async (withdrawal) =>