Skip to main content

1. Prerequisites

  • You have Bun installed.
  • A funded L1 wallet with ETH for both the withdrawal amount and L1 gas fees
Use a test network like Sepolia for experimentation.

2. Installation

Choose your adapter and install the SDK + adapter package:
bun install @dutterbutter/zksync-sdk viem dotenv
Create a .env file in your project root:
# Your funded L1 private key (0x + 64 hex)
PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE

# RPC endpoints
L1_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_ID
L2_RPC_URL=ZKSYNC-OS-TESTNET-RPC
Never commit your .env file to source control.

3. Write the withdrawal script

import {
  createPublicClient,
  createWalletClient,
  http,
  parseEther,
  type Account,
  type Chain,
  type Transport,
  type WalletClient,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

import { createViemClient, createViemSdk } from '@dutterbutter/zksync-sdk/viem';
import type { Address } from '@dutterbutter/zksync-sdk/core';
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 in the environment');

  // 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: WalletClient<Transport, Chain, Account> = createWalletClient({
    account,
    transport: http(L1_RPC),
  });
  // Need to provide an L2 wallet client for sending L2 withdraw tx
  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 as Address;

  const params = {
    token: ETH_ADDRESS, // ETH Address
    amount: parseEther('0.01'),
    to: me,
  } as const;

  const quote = await sdk.withdrawals.quote(params);
  console.log('QUOTE:', quote);

  const prepared = await sdk.withdrawals.prepare(params);
  console.log('PREPARE:', prepared);

  const created = await sdk.withdrawals.create(params);
  console.log('CREATE:', created);

  console.log('STATUS (initial):', await sdk.withdrawals.status(created));

  const l2Receipt = await sdk.withdrawals.wait(created, { for: 'l2' });
  console.log('L2 included tx:', l2Receipt?.transactionHash);

  await sdk.withdrawals.wait(created, { for: 'ready' });
  console.log('STATUS (ready):', await sdk.withdrawals.status(created));

  const fin = await sdk.withdrawals.tryFinalize(created.l2TxHash);
  console.log('TRY FINALIZE:', fin);

  const l1Receipt = await sdk.withdrawals.wait(created.l2TxHash, { for: 'finalized' });
  console.log('Finalized. L1 receipt:', l1Receipt?.transactionHash);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

4. Run it

bash title="viem" bun run withdrawals/viem.ts bash title="ethers" bun run withdrawals/ethers.ts
You’ll see logs for the L2 transaction, then L1 finalization readiness, L1 finalization execution, followed by updated balances.

5. Troubleshooting

  • Insufficient funds on L2: Ensure enough ETH for the withdrawal and L1 gas.
  • Invalid PRIVATE_KEY: Must be 0x + 64 hex chars.
  • Stuck at wait(…, { for: 'l1' }): Verify L2_RPC_URL and network health; check sdk.deposits.status(handle) to see the current phase.