Withdrawals (viem)
A fast path to withdraw ETH / ERC-20 from ZKsync (L2) → Ethereum (L1) using the viem adapter.
Withdrawals are a two-step process:
- Initiate on L2.
- Finalize on L1 to release funds.
Prerequisites
- A funded L2 account to initiate the withdrawal.
- A funded L1 account for finalization.
- RPC URLs:
L1_RPC_URL
,L2_RPC_URL
. - Installed:
@dutterbutter/zksync-sdk
+viem
.
Parameters (quick reference)
Param | Required | Meaning |
---|---|---|
token | Yes | ETH_ADDRESS or ERC-20 address |
amount | Yes | BigInt/wei (e.g. parseEther('0.01') ) |
to | Yes | L1 recipient address |
refundRecipient | No | L2 address to receive fee refunds (if applicable) |
Fast path (one-shot)
// examples/viem/withdrawals-eth.ts
import {
createPublicClient,
createWalletClient,
http,
parseEther,
type Account,
type Chain,
type Transport,
} from 'viem';
import { privateKeyToAccount, nonceManager } from 'viem/accounts';
import { createViemSdk, createViemClient } from '@dutterbutter/zksync-sdk/viem';
import { ETH_ADDRESS } from '@dutterbutter/zksync-sdk/core';
const L1_RPC = 'http://localhost:8545'; // e.g. https://sepolia.infura.io/v3/XXX
const L2_RPC = 'http://localhost:3050'; // your L2 RPC
const PRIVATE_KEY = process.env.PRIVATE_KEY || '';
async function main() {
if (!PRIVATE_KEY) {
throw new Error('Set your PRIVATE_KEY (0x-prefixed 32-byte) in env');
}
// --- Viem clients ---
const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);
const l1 = createPublicClient({ transport: http(L1_RPC) });
const l2 = createPublicClient({ transport: http(L2_RPC) });
const l1Wallet = createWalletClient<Transport, Chain, Account>({
account,
transport: http(L1_RPC),
});
const l2Wallet = createWalletClient<Transport, Chain, Account>({
account,
transport: http(L2_RPC),
});
const client = createViemClient({ l1, l2, l1Wallet, l2Wallet });
const sdk = createViemSdk(client);
const me = account.address;
// Withdraw ETH
const params = {
token: ETH_ADDRESS,
amount: parseEther('0.01'),
to: me,
// l2GasLimit: 300_000n, // optional
} as const;
// Quote (dry run)
const quote = await sdk.withdrawals.quote(params);
console.log('QUOTE:', quote);
// Prepare (no sends)
const plan = await sdk.withdrawals.prepare(params);
console.log('PREPARE:', plan);
// Create (send L2 withdraw)
const created = await sdk.withdrawals.create(params);
console.log('CREATE:', created);
// Quick status
console.log('STATUS (initial):', await sdk.withdrawals.status(created.l2TxHash));
// Wait for L2 inclusion
const l2Receipt = await sdk.withdrawals.wait(created, { for: 'l2' });
console.log(
'L2 included: block=',
l2Receipt?.blockNumber,
'status=',
l2Receipt?.status,
'hash=',
l2Receipt?.transactionHash,
);
// Wait until ready to finalize
await sdk.withdrawals.wait(created.l2TxHash, { for: 'ready' });
console.log('STATUS (ready):', await sdk.withdrawals.status(created.l2TxHash));
// Try to finalize on L1
const fin = await sdk.withdrawals.tryFinalize(created.l2TxHash);
console.log('TRY FINALIZE:', fin);
const l1Receipt = await sdk.withdrawals.wait(created.l2TxHash, { for: 'finalized' });
if (l1Receipt) {
console.log('L1 finalize receipt:', l1Receipt.transactionHash);
} else {
console.log('Finalized (no local L1 receipt — possibly finalized by someone else).');
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
create()
prepares and sends the L2 withdrawal.wait(..., { for: 'l2' })
⇒ included on L2.wait(..., { for: 'ready' })
⇒ ready for finalization.finalize(l2TxHash)
⇒ required to release funds on L1.
Inspect & customize (quote → prepare → create)
1. Quote (no side-effects)
Preview fees/steps and whether extra approvals are required.
const quote = await sdk.withdrawals.quote(params);
2. Prepare (build txs, don’t send)
Get TransactionRequest[]
for signing/UX.
const plan = await sdk.withdrawals.prepare(params);
3. Create (send)
Use defaults, or send your prepared txs if you customized.
const handle = await sdk.withdrawals.create(params);
Track progress (status vs wait)
Non-blocking snapshot
const s = await sdk.withdrawals.status(handle /* or l2TxHash */);
// 'UNKNOWN' | 'L2_PENDING' | 'PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED'
Block until checkpoint
const l2Receipt = await sdk.withdrawals.wait(handle, { for: 'l2' });
await sdk.withdrawals.wait(handle, { for: 'ready' }); // becomes finalizable
Finalization (required step)
To actually release funds on L1, call finalize
. Note
the transaction needs to be ready for finalization.
const result = await sdk.withdrawals.finalize(handle.l2TxHash);
console.log('Finalization status:', result.status.phase);
Error handling patterns
Exceptions
try {
const handle = await sdk.withdrawals.create(params);
} catch (e) {
// normalized error envelope (type, operation, message, context, optional revert)
}
No-throw style
Every method has a try*
variant (e.g. tryQuote
, tryPrepare
, tryCreate
, tryFinalize
).
These never throw—so you don’t need try/catch
. Instead they return:
{ ok: true, value: ... }
on success{ ok: false, error: ... }
on failure
This is useful for UI flows or services where you want explicit control over errors.
const r = await sdk.withdrawals.tryCreate(params);
if (!r.ok) {
console.error('Withdrawal failed:', r.error);
} else {
const handle = r.value;
const f = await sdk.withdrawals.tryFinalize(handle.l2TxHash);
if (!f.ok) {
console.error('Finalize failed:', f.error);
} else {
console.log('Withdrawal finalized on L1:', f.value.receipt?.transactionHash);
}
}
Troubleshooting
- Never reaches READY_TO_FINALIZE: proofs may not be available yet; poll
status()
orwait(..., { for: 'ready' })
. - Finalize fails: ensure you have L1 gas and check revert info in the error envelope.