diff --git a/composables/zksync/useFee.ts b/composables/zksync/useFee.ts index 6d8c6cc1..a16ddb02 100644 --- a/composables/zksync/useFee.ts +++ b/composables/zksync/useFee.ts @@ -1,19 +1,19 @@ +import { createEthersClient, createEthersSdk } from "@dutterbutter/zksync-sdk/ethers"; import { estimateGas } from "@wagmi/core"; import { AbiCoder } from "ethers"; -import { encodeFunctionData } from "viem"; +import { encodeFunctionData, type Address } from "viem"; import { wagmiConfig } from "@/data/wagmi"; import type { Token, TokenAmount } from "@/types"; import type { BigNumberish, ethers } from "ethers"; import type { Provider } from "zksync-ethers"; -import type { Address } from "zksync-ethers/build/types"; export type FeeEstimationParams = { type: "transfer" | "withdrawal"; - from: string; - to: string; - tokenAddress: string; + from: Address; + to: Address; + tokenAddress: Address; isNativeToken: boolean | null; assetId?: string | null; amount: string; @@ -25,6 +25,8 @@ export default ( tokens: Ref<{ [tokenSymbol: string]: Token } | undefined>, balances: Ref ) => { + const { getL1VoidSigner } = useZkSyncWalletStore(); + let params: FeeEstimationParams | undefined; const gasLimit = ref(); @@ -115,7 +117,7 @@ export default ( const [price, limit] = await Promise.all([ retry(() => provider.getGasPrice()), - retry(() => { + retry(async () => { const isCustomBridgeToken = !!token?.l2BridgeAddress; if (isCustomBridgeToken) { return getCustomGasLimit({ @@ -123,7 +125,7 @@ export default ( to: params!.to, token: params!.tokenAddress, amount: tokenBalance, - bridgeAddress: token?.l2BridgeAddress, + bridgeAddress: token?.l2BridgeAddress as Address, }); } else if (params!.isNativeToken && params!.assetId) { const assetData = AbiCoder.defaultAbiCoder().encode( @@ -152,13 +154,24 @@ export default ( args: [params!.assetId, assetData], }), }); - } else { - return provider[params!.type === "transfer" ? "estimateGasTransfer" : "estimateGasWithdraw"]({ + } else if (params!.type === "transfer") { + return provider.estimateGasTransfer({ from: params!.from, to: params!.to, token: params!.tokenAddress, amount: tokenBalance, }); + } else { + const signer = await getL1VoidSigner(true); + const client = createEthersClient({ l1: signer.provider, l2: signer.providerL2, signer }); + const sdk = createEthersSdk(client); + + const quote = await sdk.withdrawals.quote({ + to: params!.to, + token: params!.tokenAddress, + amount: 1n, // TODO: estimation fails if we pass actual user balance + }); + return quote.fees.gasLimit; } }), ]); diff --git a/composables/zksync/useWithdrawalFinalization.ts b/composables/zksync/useWithdrawalFinalization.ts index 547e0bca..88203ef5 100644 --- a/composables/zksync/useWithdrawalFinalization.ts +++ b/composables/zksync/useWithdrawalFinalization.ts @@ -1,44 +1,22 @@ -import { useMemoize } from "@vueuse/core"; -import { Wallet, typechain } from "zksync-ethers"; -import IL1Nullifier from "zksync-ethers/abi/IL1Nullifier.json"; - -import { L1_BRIDGE_ABI } from "@/data/abis/l1BridgeAbi"; -import { customBridgeTokens } from "@/data/customBridgeTokens"; +import { createEthersClient, createEthersSdk, createFinalizationServices } from "@dutterbutter/zksync-sdk/ethers"; import { useSentryLogger } from "../useSentryLogger"; -import type { Hash } from "@/types"; -import type { Address } from "viem"; -import type { FinalizeWithdrawalParams } from "zksync-ethers/build/types"; +import type { Hash } from "viem"; export default (transactionInfo: ComputedRef) => { const status = ref<"not-started" | "processing" | "waiting-for-signature" | "sending" | "done">("not-started"); const error = ref(); const transactionHash = ref(); const onboardStore = useOnboardStore(); - const providerStore = useZkSyncProviderStore(); const walletStore = useZkSyncWalletStore(); const tokensStore = useZkSyncTokensStore(); const { isCorrectNetworkSet } = storeToRefs(onboardStore); const { ethToken } = storeToRefs(tokensStore); const { captureException } = useSentryLogger(); - const retrieveBridgeAddresses = useMemoize(() => - providerStore.requestProvider().then((provider) => provider.getDefaultBridgeAddresses()) - ); - const retrieveL1NullifierAddress = useMemoize(async () => { - const providerL1 = await walletStore.getL1VoidSigner(); - return await typechain.IL1AssetRouter__factory.connect( - ( - await retrieveBridgeAddresses() - ).sharedL1, - providerL1 - ).L1_NULLIFIER(); - }); - const gasLimit = ref(); const gasPrice = ref(); - const finalizeWithdrawalParams = ref(); const totalFee = computed(() => { if (!gasLimit.value || !gasPrice.value) return undefined; @@ -48,101 +26,32 @@ export default (transactionInfo: ComputedRef) => { return ethToken.value; }); - const getFinalizationParams = async () => { - const provider = await providerStore.requestProvider(); - const wallet = new Wallet( - // random private key cause we don't care about actual signer - // finalizeWithdrawalParams method only exists on Wallet class - "0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110", - provider - ); - return await wallet.getFinalizeWithdrawalParams(transactionInfo.value.transactionHash); - }; - - const getTransactionParams = async () => { - finalizeWithdrawalParams.value = await getFinalizationParams(); - const provider = await providerStore.requestProvider(); - const chainId = BigInt(await provider.getNetwork().then((n) => n.chainId)); - const p = finalizeWithdrawalParams.value!; - - // Check if this is a custom bridge withdrawal - // First check if the token already has the bridge address stored - let l1BridgeAddress = transactionInfo.value.token.l1BridgeAddress; - - // If not, look it up from the custom bridge tokens configuration - if (!l1BridgeAddress) { - const { eraNetwork } = storeToRefs(providerStore); - - const customBridgeToken = customBridgeTokens.find( - (token) => - token.l2Address.toLowerCase() === transactionInfo.value.token.address.toLowerCase() && - token.chainId === eraNetwork.value.l1Network?.id - ); - - l1BridgeAddress = customBridgeToken?.l1BridgeAddress; - } - - const isCustomBridge = !!l1BridgeAddress; - - if (isCustomBridge) { - // Use custom bridge finalization - return { - address: l1BridgeAddress as Address, - abi: L1_BRIDGE_ABI, - account: onboardStore.account.address!, - functionName: "finalizeWithdrawal", - args: [ - BigInt(p.l1BatchNumber ?? 0n), - BigInt(p.l2MessageIndex), - Number(p.l2TxNumberInBlock) as number, - p.message as Hash, - p.proof as Hash[], - ], - } as const; - } else { - // Use standard bridge finalization through L1Nullifier - const finalizeDepositParams = { - chainId: BigInt(chainId), - l2BatchNumber: BigInt(p.l1BatchNumber ?? 0n), - l2MessageIndex: BigInt(p.l2MessageIndex), - l2Sender: p.sender as Address, - l2TxNumberInBatch: Number(p.l2TxNumberInBlock), - message: p.message as Hash, - merkleProof: p.proof as Hash[], - }; - - return { - address: (await retrieveL1NullifierAddress()) as Hash, - abi: IL1Nullifier, - account: onboardStore.account.address!, - functionName: "finalizeDeposit", - args: [finalizeDepositParams], - } as const; - } - }; - const { inProgress: estimationInProgress, error: estimationError, execute: estimateFee, } = usePromise( async () => { + const l2TxHash = transactionInfo.value!.transactionHash as Hash; tokensStore.requestTokens(); const publicClient = onboardStore.getPublicClient(); - const transactionParams = await getTransactionParams(); - const [price, limit] = await Promise.all([ + const [price, estimate] = await Promise.all([ retry(async () => BigInt((await publicClient.getGasPrice()).toString())), retry(async () => { - return BigInt((await publicClient.estimateContractGas(transactionParams as any)).toString()); + const signer = await walletStore.getL1VoidSigner(true); + const client = createEthersClient({ l1: signer.provider, l2: signer.providerL2, signer }); + const svc = createFinalizationServices(client); + const { params } = await svc.fetchFinalizeDepositParams(l2TxHash); + + return svc.estimateFinalization(params); }), ]); gasPrice.value = price; - gasLimit.value = limit; + gasLimit.value = estimate.gasLimit; return { - transactionParams, gasPrice: gasPrice.value, gasLimit: gasLimit.value, }; @@ -158,14 +67,15 @@ export default (transactionInfo: ComputedRef) => { if (!isCorrectNetworkSet.value) { await onboardStore.setCorrectNetwork(); } - const wallet = await onboardStore.getWallet(); - const { transactionParams, gasLimit, gasPrice } = (await estimateFee())!; status.value = "waiting-for-signature"; - transactionHash.value = await wallet.writeContract({ - ...(transactionParams as any), - gasPrice: BigInt(gasPrice.toString()), - gas: BigInt(gasLimit.toString()), - }); + const signer = (await walletStore.getL1Signer())!; + const client = createEthersClient({ l1: signer.provider, l2: signer.providerL2, signer }); + const sdk = createEthersSdk(client); + const transaction = await sdk.withdrawals.finalize(transactionInfo.value!.transactionHash as Hash); + if (!transaction.receipt) { + throw new Error("Finalization transaction failed"); + } + transactionHash.value = transaction.receipt?.hash as Hash; status.value = "sending"; const receipt = await retry(() => @@ -186,6 +96,8 @@ export default (transactionInfo: ComputedRef) => { status.value = "done"; return receipt; } catch (err) { + // eslint-disable-next-line no-console + console.error(err); error.value = formatError(err as Error); status.value = "not-started"; captureException({ diff --git a/package-lock.json b/package-lock.json index 499ce66e..74ca9351 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "@ankr.com/ankr.js": "^0.5.0", "@chenfengyuan/vue-qrcode": "^2.0.0", - "@dutterbutter/zksync-sdk": "^0.0.11-alpha", + "@dutterbutter/zksync-sdk": "^0.0.12-alpha", "@lottiefiles/dotlottie-vue": "^0.6.0", "@sentry/vue": "^9.0.0", "@vitejs/plugin-vue": "^3.2.0", @@ -1520,9 +1520,9 @@ "license": "MIT" }, "node_modules/@dutterbutter/zksync-sdk": { - "version": "0.0.11-alpha", - "resolved": "https://registry.npmjs.org/@dutterbutter/zksync-sdk/-/zksync-sdk-0.0.11-alpha.tgz", - "integrity": "sha512-PHn0mYKAY1lbF8RQM5ejLjtdT/smvbHXeQ/SWE+nEV/M9qmynz+J6rjLnL4u+vmS/C/ABgdpgyjuZLMyRoObew==", + "version": "0.0.12-alpha", + "resolved": "https://registry.npmjs.org/@dutterbutter/zksync-sdk/-/zksync-sdk-0.0.12-alpha.tgz", + "integrity": "sha512-yfwxPSiXvGWc0Tj/vt6aKllE9jhiZ8kRAiSK9GzpzblOqdzVzCXqO25JbbLQAsRZVb7HffQbJsCxSB9VNmmirQ==", "license": "MIT", "dependencies": { "@noble/hashes": "^1.8.0" diff --git a/package.json b/package.json index 3e1f2c7e..3a417c61 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "dependencies": { "@ankr.com/ankr.js": "^0.5.0", "@chenfengyuan/vue-qrcode": "^2.0.0", - "@dutterbutter/zksync-sdk": "^0.0.11-alpha", + "@dutterbutter/zksync-sdk": "^0.0.12-alpha", "@lottiefiles/dotlottie-vue": "^0.6.0", "@sentry/vue": "^9.0.0", "@vitejs/plugin-vue": "^3.2.0", diff --git a/store/zksync/transactionStatus.ts b/store/zksync/transactionStatus.ts index 87003a47..2875728e 100644 --- a/store/zksync/transactionStatus.ts +++ b/store/zksync/transactionStatus.ts @@ -1,11 +1,8 @@ +import { createEthersClient, createEthersSdk } from "@dutterbutter/zksync-sdk/ethers"; import { useStorage } from "@vueuse/core"; -import { decodeEventLog, type Address } from "viem"; -import { Wallet, typechain } from "zksync-ethers"; -import IL1Nullifier from "zksync-ethers/abi/IL1Nullifier.json"; +import { decodeEventLog } from "viem"; import IZkSyncHyperchain from "zksync-ethers/abi/IZkSyncHyperchain.json"; -import { selectL2ToL1LogIndex, isLocalRootIsZero } from "@/utils/helpers"; - import type { FeeEstimationParams } from "@/composables/zksync/useFee"; import type { TokenAmount, Hash } from "@/types"; @@ -26,7 +23,7 @@ export type TransactionInfo = { }; export const ESTIMATED_DEPOSIT_DELAY = 15 * 1000; // 15 seconds -export const WITHDRAWAL_DELAY = 5 * 60 * 60 * 1000; // 5 hours +export const WITHDRAWAL_DELAY = 5 * 60 * 1000; // 5 minutes // @zksyncos ZKsyncOS does not include getTransactionDetails so using executeTxHash as an // indicator of finalization readiness is not available. Instead (a bit hacky), we first check @@ -38,6 +35,7 @@ export const WITHDRAWAL_DELAY = 5 * 60 * 60 * 1000; // 5 hours export const useZkSyncTransactionStatusStore = defineStore("zkSyncTransactionStatus", () => { const onboardStore = useOnboardStore(); const providerStore = useZkSyncProviderStore(); + const { getL1VoidSigner } = useZkSyncWalletStore(); const { account } = storeToRefs(onboardStore); const { eraNetwork } = storeToRefs(providerStore); @@ -142,87 +140,30 @@ export const useZkSyncTransactionStatusStore = defineStore("zkSyncTransactionSta return transaction; } - // Check if we already decided finalization is available; if not, try to ensure inclusion - if (!transaction.info.withdrawalFinalizationAvailable) { - const l2ToL1Logs = (receipt as any).l2ToL1Logs ?? (receipt as any).l2ToL1LogsRaw ?? []; + const signer = await getL1VoidSigner(true); - const logIndex = selectL2ToL1LogIndex(l2ToL1Logs); - if (logIndex === null) { - // No L2→L1 log yet → not provable, so not included in an L1 batch yet + const client = createEthersClient({ l1: signer.provider, l2: signer.providerL2, signer }); + const sdk = createEthersSdk(client); + const status = await sdk.withdrawals.status(transaction.transactionHash as Hash); + switch (status.phase) { + case "FINALIZED": + transaction.info.completed = true; + transaction.info.failed = false; + transaction.info.withdrawalFinalizationAvailable = false; return transaction; - } - - // Ask provider for a proof; if present, tx is included in an L1 batch (not yet proved/executed) - let hasProof = false; - try { - const proof = await provider.getLogProof(transaction.transactionHash, logIndex); - hasProof = !!proof; - } catch { - hasProof = false; - } - - if (!hasProof) { - // Not provable yet → wait + case "FINALIZE_FAILED": + transaction.info.completed = true; + transaction.info.failed = true; + transaction.info.withdrawalFinalizationAvailable = false; return transaction; - } - - try { - // Build finalize params for the purpose of ensuring tx is ready for finalization - // This replaces the use zks_getTransactionDetails and checking if executeTxHash was present - // TODO (zksyncos) Hacky: can be improved upon - const wallet = new Wallet("0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110", provider); - const p = await wallet.getFinalizeWithdrawalParams(transaction.transactionHash); - - const l1Signer = await useZkSyncWalletStore().getL1VoidSigner(true); - const bridges = await provider.getDefaultBridgeAddresses(); - const l1NullifierAddr = await typechain.IL1AssetRouter__factory.connect( - bridges.sharedL1, - l1Signer - ).L1_NULLIFIER(); - - const publicClient = useOnboardStore().getPublicClient(); - const chainId = BigInt((await provider.getNetwork()).chainId); - - const finalizeDepositParams = { - chainId, - l2BatchNumber: BigInt(p.l1BatchNumber ?? 0n), - l2MessageIndex: BigInt(p.l2MessageIndex), - l2Sender: p.sender as Address, - l2TxNumberInBatch: Number(p.l2TxNumberInBlock), - message: p.message as Hash, - merkleProof: p.proof as readonly Hash[], - }; - - const res = await publicClient.estimateContractGas({ - address: l1NullifierAddr as Address, - abi: IL1Nullifier, - functionName: "finalizeDeposit", - args: [finalizeDepositParams], - }); - console.log("res", res); // eslint-disable-line no-console - - // If we got here, call is acceptable → finalization is available + case "READY_TO_FINALIZE": + transaction.info.completed = false; + transaction.info.failed = false; transaction.info.withdrawalFinalizationAvailable = true; - } catch (err) { - // This will signal finalization is not yet available - if (isLocalRootIsZero(err)) { - // Batch not executed yet → keep finalization unavailable - transaction.info.withdrawalFinalizationAvailable = false; - transaction.info.completed = false; - transaction.info.failed = false; - return transaction; - } - // other revert (e.g., already finalized) will be handled below - } + return transaction; + default: + return transaction; } - - // Finalization check on L1 - const l1signer = await useZkSyncWalletStore().getL1VoidSigner(true); - const isFinalized = await l1signer.isWithdrawalFinalized(transaction.transactionHash).catch(() => false); - - transaction.info.completed = isFinalized; - transaction.info.failed = false; - return transaction; }; const getTransferStatus = async (transaction: TransactionInfo) => { const provider = await providerStore.requestProvider(); diff --git a/store/zksync/withdrawals.ts b/store/zksync/withdrawals.ts index a9342d14..029b8c2c 100644 --- a/store/zksync/withdrawals.ts +++ b/store/zksync/withdrawals.ts @@ -1,6 +1,8 @@ +import { createEthersClient, createEthersSdk } from "@dutterbutter/zksync-sdk/ethers"; import { $fetch } from "ofetch"; import type { Api } from "@/types"; +import type { Hash } from "viem"; const FETCH_TIME_LIMIT = 31 * 24 * 60 * 60 * 1000; // 31 days @@ -8,6 +10,7 @@ export const useZkSyncWithdrawalsStore = defineStore("zkSyncWithdrawals", () => const onboardStore = useOnboardStore(); const providerStore = useZkSyncProviderStore(); const transactionStatusStore = useZkSyncTransactionStatusStore(); + const { getL1VoidSigner } = useZkSyncWalletStore(); const { account, isConnected } = storeToRefs(onboardStore); const { eraNetwork } = storeToRefs(providerStore); const { userTransactions } = storeToRefs(transactionStatusStore); @@ -30,9 +33,10 @@ export const useZkSyncWithdrawalsStore = defineStore("zkSyncWithdrawals", () => if (new Date(withdrawal.timestamp).getTime() < Date.now() - FETCH_TIME_LIMIT) break; - const isFinalized = await (await useZkSyncWalletStore().getL1VoidSigner(true)) - ?.isWithdrawalFinalized(withdrawal.transactionHash) - .catch(() => false); + const signer = await getL1VoidSigner(true); + const client = createEthersClient({ l1: signer.provider, l2: signer.providerL2, signer }); + const sdk = createEthersSdk(client); + const status = await sdk.withdrawals.status(withdrawal.transactionHash as Hash); transactionStatusStore.saveTransaction({ type: "withdrawal", @@ -54,8 +58,8 @@ export const useZkSyncWithdrawalsStore = defineStore("zkSyncWithdrawals", () => expectedCompleteTimestamp: new Date( new Date(withdrawal.timestamp).getTime() + WITHDRAWAL_DELAY ).toISOString(), - completed: isFinalized, - withdrawalFinalizationAvailable: isFinalized, + completed: status.phase === "FINALIZED", + withdrawalFinalizationAvailable: ["FINALIZE_FAILED", "READY_TO_FINALIZE"].includes(status.phase), }, }); } diff --git a/utils/constants.ts b/utils/constants.ts index 300cacbb..1605ef27 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -1,5 +1,7 @@ import { utils } from "zksync-ethers"; -export const L2_BASE_TOKEN_ADDRESS = utils.ETH_ADDRESS_IN_CONTRACTS; -export const L2_NATIVE_TOKEN_VAULT_ADDRESS = utils.L2_NATIVE_TOKEN_VAULT_ADDRESS; -export const L2_ASSET_ROUTER_ADDRESS = utils.L2_ASSET_ROUTER_ADDRESS; +import type { Address } from "viem"; + +export const L2_BASE_TOKEN_ADDRESS = utils.ETH_ADDRESS_IN_CONTRACTS as Address; +export const L2_NATIVE_TOKEN_VAULT_ADDRESS = utils.L2_NATIVE_TOKEN_VAULT_ADDRESS as Address; +export const L2_ASSET_ROUTER_ADDRESS = utils.L2_ASSET_ROUTER_ADDRESS as Address; diff --git a/utils/doc-links.ts b/utils/doc-links.ts index 1995b072..0aa161fa 100644 --- a/utils/doc-links.ts +++ b/utils/doc-links.ts @@ -1,3 +1 @@ export const TOKEN_ALLOWANCE = "https://cryptotesters.com/blog/token-allowances"; - -export const ZKSYNC_WITHDRAWAL_DELAY = "https://docs.zksync.io/build/support/withdrawal-delay.html#withdrawal-delay"; diff --git a/views/transactions/Transfer.vue b/views/transactions/Transfer.vue index 4061e331..15c2c65f 100644 --- a/views/transactions/Transfer.vue +++ b/views/transactions/Transfer.vue @@ -117,11 +117,9 @@