Overview
The @zksync-sdk
is a lightweight extension for viem
and ethers
that makes ZKsync cross-chain actions simple and consistent.
Instead of re-implementing accounts or RPC logic, this SDK focuses only on ZKsync-specific flows:
- Deposits (L1 → L2)
- Withdrawals (L2 → L1, with finalization)
- Status & wait helpers
- ZKsync specific JSON-RPC methods
What you’ll find here
- What this SDK does — the purpose, scope, and non-goals.
- Mental model — how to think about the core methods (
quote → prepare → create → status → wait → finalize
). - Adapters (viem & ethers) — how the SDK integrates with your existing stack.
Next steps
👉 If you want to get hands-on right away, jump to the Quickstart.
What this SDK Does
The @zksync-sdk
is a lightweight, powerful extension for the popular ethers.js
and viem
libraries. Its purpose is to simplify the development of applications on ZKsync by providing straightforward access to ZKsync-specific features that are not natively available in the core Ethereum SDKs.
Think of it as a specialized toolkit that sits on top of the tools you already know and love, enabling you to seamlessly interact with both L1 and L2 functionalities of the Elastic Network.
Audience
This SDK is designed for Web3 developers, dApp builders, and infrastructure engineers who are building applications on or interacting with the Elastic Network. If you're comfortable with ethers.js
or viem
and need to implement ZKsync-specific actions, this library is for you.
Scope
The SDK currently supports ZKsync specific actions, primarily L1-L2, and L2-L1 transactions.
Key Supported Features
-
Deposits (L1 → L2) — ETH and ERC-20
- Initiate on L1: build and send the deposit transaction from Ethereum.
- Track progress: query intermediate states (queued, included, executed).
- Verify completion on L2: confirm funds credited/available on ZKsync (L2).
-
Withdrawals (L2 → L1) — ETH and ERC-20
- Initiate on L2: create the withdrawal transaction on ZKsync (L2).
- Track progress: monitor execution and finalization availability.
- Finalize on L1: Finalize withdrawal to release funds (L1).
-
ZKsync RPC
getBridgehubAddress
(zks_getBridgehubContract
)
Resolve the canonical Bridgehub contract address.getL2ToL1LogProof
(zks_getL2ToL1LogProof
)
Retrieves the log proof for an L2 to L1 transaction.getGenesis
(zks_getGenesis
)
Fetches the genesis configuration (initial contracts, storage entries, execution version, root hash).getReceiptWithL2ToL1
(receipt extension)
Returns an EthereumTransactionReceipt
augmented withl2ToL1Logs
.
Non-Goals
To maintain its focus and lightweight nature, this SDK explicitly avoids duplicating functionality that is already well-handled by ethers.js
, viem
, or other dedicated libraries.
The following are out of scope:
- Wallet Management & Signing: The SDK does not manage private keys, mnemonics, or other sensitive credentials. It expects a pre-configured Signer or Wallet Client from
ethers
orviem
. Key storage and transaction signing are delegated to these underlying libraries. - Generic Ethereum Interactions: Standard Ethereum transactions, contract calls, or RPC methods that are not specific to ZKsync should be handled directly by
ethers
orviem
.
ℹ️ Runtime compatibility follows the adapter you choose (viem
or ethers
).
See their docs for environment support.
Mental Model
The SDK is designed around a predictable and layered API for handling L1-L2, and L2-L1 operations. Every action, whether it's a deposit or a withdrawal, follows a consistent lifecycle. Understanding this lifecycle is key to using the SDK effectively.
The complete lifecycle for any action is:
quote → prepare → create → status → wait → (finalize*)
- The first five steps are common to both Deposits and Withdrawals.
- Withdrawals require an additional
finalize
step to prove and claim the funds on L1.
You can enter this lifecycle at different stages depending on how much control you need.
The Core API: A Layered Approach
The core methods are designed to give you progressively more automation. You can start by just getting information (quote
), move to building transactions without sending them (prepare
), or execute the entire flow with a single call (create
).
quote(params)
"What will this operation involve and cost?"
This is a read-only dry run. It performs no transactions and has no side effects. It inspects the parameters and returns a Quote
object containing the estimated fees, gas costs, and the steps the SDK will take to complete the action.
➡️ Best for: Displaying a confirmation screen to a user with a cost estimate before they commit.
prepare(params)
"Build the transactions for me, but let me send them."
This method constructs all the necessary transactions for the operation and returns them as an array of TransactionRequest
objects in a Plan
. It does not sign or send them. This gives you full control over the final execution.
➡️ Best for: Custom workflows where you need to inspect transactions before signing, use a unique signing method, or submit them through a separate system (like a multisig).
create(params)
"Prepare, sign, and send in one go."
This is the most common entry point for a one-shot operation. It internally calls prepare
, then uses your configured signer to sign and dispatch the transactions. It returns a Handle
object, which is a lightweight tracker containing the transaction hash(es) needed for the next steps.
➡️ Best for: Most standard use cases where you simply want to initiate the deposit or withdrawal.
status(handle | txHash)
"Where is my transaction right now?"
This is a non-blocking check to get the current state of an operation. It takes a Handle
from the create
method or a transaction hash and returns a structured status object, such as:
- Deposits:
{ phase: 'L1_PENDING' | 'L2_EXECUTED' }
- Withdrawals:
{ phase: 'L1_INCLUDED','L2_PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED' }
➡️ Best for: Polling in a UI to show a user the live progress of their transaction without blocking the interface.
wait(handle, { for })
"Pause until a specific checkpoint is reached."
This is a blocking (asynchronous) method that polls for you. It pauses execution until the operation reaches a desired checkpoint and then resolves with the relevant transaction receipt.
- Deposits: Wait for L1 inclusion (
'l1'
) or L2 execution ('l2'
). - Withdrawals: Wait for L2 inclusion (
'l2'
), finalization availability ('ready'
), or final L1 finalization ('finalized'
).
➡️ Best for: Scripts or backend processes where you need to ensure one step is complete before starting the next.
finalize(l2TxHash)
(Withdrawals Only)
"My funds are ready on L1. Finalize and release them."
This method executes the final step of a withdrawal. After status
reports READY_TO_FINALIZE
, you call this method with the L2 transaction hash to submit the finalization transaction on L1, which releases the funds to the recipient.
➡️ Best for: The final step of any withdrawal flow.
Error Handling: The try*
Philosophy
For more robust error handling without try/catch
blocks, every core method has a try*
variant (e.g., tryQuote
, tryCreate
).
Instead of throwing an error on failure, these methods return a result object that enforces explicit error handling:
// Instead of this:
try {
const handle = await sdk.withdrawals.create(params);
// ... happy path
} catch (error) {
// ... sad path
}
// You can do this:
const result = await sdk.withdrawals.tryCreate(params);
if (result.ok) {
// Safe to use result.value, which is the WithdrawHandle
const handle = result.value;
} else {
// Handle the error explicitly
console.error('Withdrawal failed:', result.error);
}
➡️ Best for: Applications that prefer a functional error-handling pattern and want to avoid uncaught exceptions.
Putting It All Together
These primitives allow you to compose flows that are as simple or as complex as you need.
Simple Flow
Use create
and wait
for the most straightforward path.
// 1. Create the deposit
const depositHandle = await sdk.deposits.create(params);
// 2. Wait for it to be finalized on L2
const receipt = await sdk.deposits.wait(depositHandle, { for: 'l2' });
console.log('Deposit complete!');
Adapters: viem
& ethers
The SDK is designed to work with the tools you already know and love. It's not a standalone library, but rather an extension that plugs into your existing viem
or ethers.js
setup.
Think of it like a power adapter 🔌. You have your device (viem
or ethers
client), and this SDK adapts it to work seamlessly with zkSync's unique features. You bring your own client, and the SDK enhances it.
Why an Adapter Model?
This approach offers several key advantages:
- ✅ Bring Your Own Stack: You don't have to replace your existing setup. The SDK integrates directly with the
viem
clients (PublicClient
,WalletClient
) orethers
providers and signers you're already using. - 📚 Familiar Developer Experience (DX): You continue to handle connections, accounts, and signing just as you always have.
- 🧩 Lightweight & Focused: The SDK remains small and focused on one thing: providing a robust API for ZKsync-specific actions like deposits and withdrawals.
Installation
First, install the core SDK, then add the adapter that matches your project's stack.
# For viem users
npm install @dutterbutter/zksync-sdk viem
# For ethers.js users
npm install @dutterbutter/zksync-sdk ethers
How to Use
The SDK extends your existing client. Configure viem or ethers as you normally would, then pass them into the adapter’s client factory and create the SDK surface.
viem (public + wallet client)
import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } from '@dutterbutter/zksync-sdk/viem';
import { ETH_ADDRESS } from '@dutterbutter/zksync-sdk/core';
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const l1 = createPublicClient({ transport: http(process.env.L1_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.L2_RPC!) });
const l1Wallet = createWalletClient({ account, transport: http(process.env.L1_RPC!) });
const client = createViemClient({ l1, l2, l1Wallet });
const sdk = createViemSdk(client);
const params = {
amount: parseEther('0.01'),
to: account.address,
token: ETH_ADDRESS,
} as const;
const handle = await sdk.deposits.create(params);
await sdk.deposits.wait(handle, { for: 'l2' }); // funds available on L2
ethers (providers + signer)
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@dutterbutter/zksync-sdk/ethers';
import { ETH_ADDRESS } from '@dutterbutter/zksync-sdk/core';
const l1 = new JsonRpcProvider(process.env.L1_RPC!);
const l2 = new JsonRpcProvider(process.env.L2_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);
const client = await createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);
const params = {
amount: parseEther('0.01'),
to: await signer.getAddress(),
token: ETH_ADDRESS,
} as const;
const handle = await sdk.deposits.create(params);
await sdk.deposits.wait(handle, { for: 'l2' }); // funds available on L2
Key Principles
- No Key Management: The SDK never asks for or stores private keys. All signing operations are delegated to the
viem
WalletClient
orethers
Signer
you provide. - API Parity: Both adapters expose the exact same API. The code you write to call
client.deposits.quote()
is identical whether you're usingviem
orethers
. - Easy Migration: Because the API is the same, switching your project from
ethers
toviem
(or vice versa) is incredibly simple. You only need to change the initialization code.
Quickstart
The Quickstart guides help you get your first ZKsync deposit action running in minutes.
You’ll learn how to install the SDK, connect a client, and perform a deposit.
Choose your adapter
This SDK extends existing Ethereum libraries. Pick the Quickstart that matches your stack:
- Quickstart (viem) — for projects already using viem.
- Quickstart (ethers) — for projects using ethers v6.
What you’ll do
Each Quickstart walks you through:
- Install the adapter package.
- Configure a client or signer.
- Run a deposit (L1 → L2) as a working example.
- Track the status until it’s complete.
👉 Once you’re set up, continue to the How-to Guides for more detailed usage.
Choosing Your Adapter: viem
vs. ethers
The SDK is designed to work with both viem
and ethers.js
, the two most popular Ethereum libraries. Since the SDK offers identical functionality for both, the choice comes down to your project's needs and your personal preference.
The Short Answer (TL;DR)
- If you're adding the SDK to an existing project: Use the adapter for the library you're already using.
- If you're starting a new project: The choice is yours.
viem
is generally recommended for new projects due to its modern design, smaller bundle size, and excellent TypeScript support.
You can't make a wrong choice. Both adapters are fully supported and provide the same features.
Code Comparison
The only difference in your code is the initial setup. All subsequent SDK calls are identical.
viem
import { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } from '@dutterbutter/zksync-sdk/viem';
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const l1 = createPublicClient({ transport: http(process.env.L1_RPC!) });
const l2 = createPublicClient({ transport: http(process.env.L2_RPC!) });
const l1Wallet = createWalletClient({ account, transport: http(process.env.L1_RPC!) });
const client = createViemClient({ l1, l2, l1Wallet });
const sdk = createViemSdk(client);
ethers
import { JsonRpcProvider, Wallet } from 'ethers';
import { createEthersClient, createEthersSdk } from '@dutterbutter/zksync-sdk/ethers';
const l1 = new JsonRpcProvider(process.env.L1_RPC!);
const l2 = new JsonRpcProvider(process.env.L2_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);
const client = await createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);
Identical SDK Usage
Once the adapter is set up, your application logic is the same:
const quote = await sdk.deposits.quote({
token: ETH_ADDRESS,
amount: parseEther('0.1'),
to: '0xYourAddress',
});
console.log('Total fee:', quote.totalFee.toString());
Conclusion
The adapter model is designed to give you flexibility without adding complexity. Your choice of adapter is a low-stakes decision that's easy to change later.
Ready to start building? 🚀
Quickstart (viem): ETH Deposit (L1 → L2)
This guide gets you to a working ETH deposit from Ethereum to ZKsync (L2) using the viem adapter.
You’ll set up your environment, write a short script, and run it.
1. Prerequisites
- You have Bun (or Node + tsx) installed.
- You have an L1 wallet funded with ETH to cover the deposit amount and L1 gas.
2. Installation & Setup
Install packages:
bun install @dutterbutter/zksync-sdk viem dotenv
# or: npm i @dutterbutter/zksync-sdk viem dotenv
Create an .env
in your project root (never commit this):
# 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
3. The Deposit Script
Save as deposit-viem.ts
:
import 'dotenv/config'; // Load environment variables from .env
import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { createViemClient, createViemSdk } from '@dutterbutter/zksync-sdk/viem';
import { ETH_ADDRESS } from '@dutterbutter/zksync-sdk/core';
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const L1_RPC_URL = process.env.L1_RPC_URL;
const L2_RPC_URL = process.env.L2_RPC_URL;
async function main() {
if (!PRIVATE_KEY || !L1_RPC_URL || !L2_RPC_URL) {
throw new Error('Please set your PRIVATE_KEY, L1_RPC_URL, and L2_RPC_URL in a .env file');
}
// 1. SET UP CLIENTS AND ACCOUNT
// The SDK needs connections to both L1 and L2 to function.
const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);
const l1 = createPublicClient({ transport: http(L1_RPC_URL) });
const l2 = createPublicClient({ transport: http(L2_RPC_URL) });
const l1Wallet = createWalletClient({ account, transport: http(L1_RPC_URL) });
// 2. INITIALIZE THE SDK CLIENT
// The client bundles your viem clients; the SDK surface exposes deposits/withdrawals helpers.
const client = createViemClient({ l1, l2, l1Wallet });
const sdk = createViemSdk(client);
const L1balance = await l1.getBalance({ address: account.address });
const L2balance = await l2.getBalance({ address: account.address });
console.log('Wallet balance on L1:', L1balance);
console.log('Wallet balance on L2:', L2balance);
// 3. PERFORM THE DEPOSIT
// The create() method prepares and sends the transaction.
// The wait() method polls until the transaction is complete.
console.log('Sending deposit transaction...');
const depositHandle = await sdk.deposits.create({
token: ETH_ADDRESS,
amount: parseEther('0.001'), // 0.001 ETH
to: account.address,
});
console.log(`L1 transaction hash: ${depositHandle.l1TxHash}`);
console.log('Waiting for the deposit to be confirmed on L1...');
// Wait for L1 inclusion
const l1Receipt = await sdk.deposits.wait(depositHandle, { for: 'l1' });
console.log(`Deposit confirmed on L1 in block ${l1Receipt?.blockNumber}`);
console.log('Waiting for the deposit to be executed on L2...');
// Wait for L2 execution
const l2Receipt = await sdk.deposits.wait(depositHandle, { for: 'l2' });
console.log(`Deposit executed on L2 in block ${l2Receipt?.blockNumber}`);
console.log('Deposit complete! ✅');
const L1balanceAfter = await l1.getBalance({ address: account.address });
const L2balanceAfter = await l2.getBalance({ address: account.address });
console.log('Wallet balance on L1 after:', L1balanceAfter);
console.log('Wallet balance on L2 after:', L2balanceAfter);
/*
// OPTIONAL: ADVANCED CONTROL
// The SDK also lets you inspect a transaction before sending it.
// This follows the Mental Model: quote -> prepare -> create.
// Uncomment the code below to see it in action.
const params = {
token: ETH_ADDRESS,
amount: parseEther('0.001'),
to: account.address,
};
// Get a quote for the fees
const quote = await sdk.deposits.quote(params);
console.log('Fee quote:', quote);
// Prepare the transaction without sending
const plan = await sdk.deposits.prepare(params);
console.log('Transaction plan:', plan);
*/
}
main().catch((error) => {
console.error('An error occurred:', error);
process.exit(1);
});
4. Run the Script
bun run deposit-viem.ts
# or with tsx:
# npx tsx deposit-viem.ts
You’ll see logs for the L1 transaction, then L2 execution, and a final status snapshot.
5. Troubleshooting
- Insufficient funds on L1: Ensure enough ETH for the deposit and L1 gas.
- Invalid
PRIVATE_KEY
: Must be0x
+ 64 hex chars. - Stuck at
wait(..., { for: 'l2' })
: VerifyL2_RPC_URL
and network health; checksdk.deposits.status(handle)
to see the current phase. - ERC-20 deposits: May require an L1
approve()
;quote()
will surface required steps.
Quickstart (ethers): ETH Deposit (L1 → L2)
This guide will get you from zero to a working ETH deposit from Ethereum to ZKsync (L2) in minutes using the ethers adapter. 🚀
You'll set up your environment, write a short script to make a deposit, and run it.
1. Prerequisites
- You have Bun installed.
- You have an L1 wallet (e.g., Sepolia testnet) funded with some ETH to pay for gas and the deposit.
2. Installation & Setup
First, install the necessary packages.
bun install @dutterbutter/zksync-sdk ethers dotenv
Next, create a .env
file in your project's root directory to store your private key and RPC endpoints. Never commit this file to Git.
.env
file:
# Your funded L1 wallet private key (e.g., from MetaMask)
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"
3. The Deposit Script
The following script will connect to the networks, create a deposit transaction, send it, and wait for it to be confirmed on both L1 and L2.
Save this code as deposit-ethers.ts
:
import 'dotenv/config'; // Load environment variables from .env
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient } from '@dutterbutter/zksync-sdk/ethers';
import { ETH_ADDRESS } from '@dutterbutter/zksync-sdk/core';
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const L1_RPC_URL = process.env.L1_RPC_URL;
const L2_RPC_URL = process.env.L2_RPC_URL;
async function main() {
if (!PRIVATE_KEY || !L1_RPC_URL || !L2_RPC_URL) {
throw new Error('Please set your PRIVATE_KEY, L1_RPC_URL, and L2_RPC_URL in a .env file');
}
// 1. SET UP PROVIDERS AND SIGNER
// The SDK needs connections to both L1 and L2 to function.
const l1Provider = new JsonRpcProvider(L1_RPC_URL);
const l2Provider = new JsonRpcProvider(L2_RPC_URL);
const signer = new Wallet(PRIVATE_KEY, l1Provider);
// 2. INITIALIZE THE SDK CLIENT
// The client is the low-level interface for interacting with the API.
const client = await createEthersClient({
l1Provider,
l2Provider,
signer,
});
const L1balance = await l1.getBalance({ address: signer.address });
const L2balance = await l2.getBalance({ address: signer.address });
console.log('Wallet balance on L1:', L1balance);
console.log('Wallet balance on L2:', L2balance);
// 3. PERFORM THE DEPOSIT
// The create() method prepares and sends the transaction.
// The wait() method polls until the transaction is complete.
console.log('Sending deposit transaction...');
const depositHandle = await sdk.deposits.create({
token: ETH_ADDRESS,
amount: parseEther('0.001'), // 0.001 ETH
to: account.address,
});
console.log(`L1 transaction hash: ${depositHandle.l1TxHash}`);
console.log('Waiting for the deposit to be confirmed on L1...');
// Wait for L1 inclusion
const l1Receipt = await sdk.deposits.wait(depositHandle, { for: 'l1' });
console.log(`Deposit confirmed on L1 in block ${l1Receipt?.blockNumber}`);
console.log('Waiting for the deposit to be executed on L2...');
// Wait for L2 execution
const l2Receipt = await sdk.deposits.wait(depositHandle, { for: 'l2' });
console.log(`Deposit executed on L2 in block ${l2Receipt?.blockNumber}`);
console.log('Deposit complete! ✅');
const L1balanceAfter = await l1.getBalance({ address: signer.address });
const L2balanceAfter = await l2.getBalance({ address: signer.address });
console.log('Wallet balance on L1 after:', L1balanceAfter);
console.log('Wallet balance on L2 after:', L2balanceAfter);
/*
// OPTIONAL: ADVANCED CONTROL
// The SDK also lets you inspect a transaction before sending it.
// This follows the Mental Model: quote -> prepare -> create.
// Uncomment the code below to see it in action.
const params = {
token: ETH_ADDRESS,
amount: parseEther('0.001'),
to: account.address,
};
// Get a quote for the fees
const quote = await sdk.deposits.quote(params);
console.log('Fee quote:', quote);
// Prepare the transaction without sending
const plan = await sdk.deposits.prepare(params);
console.log('Transaction plan:', plan);
*/
}
main().catch((error) => {
console.error('An error occurred:', error);
process.exit(1);
});
4. Run the Script
Execute the script using bun
.
bun run deposit-ethers.ts
You should see output confirming the L1 transaction, the wait periods, and finally the successful L2 verification.
5. Troubleshooting
- Insufficient funds on L1: Make sure your wallet has enough ETH on L1 to cover both the deposit amount (
0.001
ETH) and the L1 gas fees. - Invalid
PRIVATE_KEY
: Ensure it’s a 64-character hex string, prefixed with0x
. - Stuck waiting for L2: This can take a few minutes. If it takes too long, check that your
L2_RPC_URL
is correct and the network is operational.
How-to Guides
This section provides task-focused recipes for deposits and withdrawals with the adapter of your choice.
Each guide shows the minimal steps to accomplish a task using the SDK, with real code you can copy, paste, and run.
When to use Guides
- Use these guides if you want to get something working quickly (e.g., deposit ETH, withdraw ERC-20).
- If you need a deeper explanation of the SDK’s design, check Concepts.
Available Guides
Deposits
Withdrawals
Deposits (viem)
A fast path to deposit ETH / ERC-20 from L1 → ZKsync (L2) using the viem adapter.
Prerequisites
- A funded L1 account (gas + amount).
- 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 | L2 recipient address |
l2GasLimit | No | L2 execution gas cap |
gasPerPubdata | No | Pubdata price hint |
operatorTip | No | Optional tip to operator |
refundRecipient | No | L2 address to receive fee refunds |
ERC-20 deposits may require an L1
approve()
.quote()
surfaces required steps.
Fast path (one-shot)
// examples/deposit-eth.ts
import { createPublicClient, createWalletClient, http, parseEther, WalletClient } from 'viem';
import type { Account, Chain, Transport } from 'viem';
import { privateKeyToAccount } 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 || PRIVATE_KEY.length !== 66) {
throw new Error('Set your PRIVATE_KEY in the .env file');
}
// --- 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: WalletClient<Transport, Chain, Account> = createWalletClient({
account,
transport: http(L1_RPC),
});
// Check balances
const [balL1, balL2] = await Promise.all([
l1.getBalance({ address: account.address }),
l2.getBalance({ address: account.address }),
]);
console.log('L1 balance:', balL1.toString());
console.log('L2 balance:', balL2.toString());
// client + sdk
const client = createViemClient({ l1, l2, l1Wallet });
const sdk = createViemSdk(client);
const me = account.address;
const params = {
amount: parseEther('0.01'), // 0.01 ETH
to: me,
token: ETH_ADDRESS,
// optional:
// l2GasLimit: 300_000n,
// gasPerPubdata: 800n,
// operatorTip: 0n,
// refundRecipient: me,
} as const;
// Quote
const quote = await sdk.deposits.quote(params);
console.log('QUOTE response:', quote);
// Prepare (route + steps, no sends)
const prepared = await sdk.deposits.prepare(params);
console.log('PREPARE response:', prepared);
// Create (prepare + send)
const created = await sdk.deposits.create(params);
console.log('CREATE response:', created);
// Status (quick check)
const status = await sdk.deposits.status(created);
console.log('STATUS response:', status);
// Wait (L1 inclusion)
const l1Receipt = await sdk.deposits.wait(created, { for: 'l1' });
console.log(
'L1 Included at block:',
l1Receipt?.blockNumber,
'status:',
l1Receipt?.status,
'hash:',
l1Receipt?.transactionHash,
);
// Status again
const status2 = await sdk.deposits.status(created);
console.log('STATUS2 response:', status2);
// Wait for L2 execution
const l2Receipt = await sdk.deposits.wait(created, { for: 'l2' });
console.log(
'L2 Included at block:',
l2Receipt?.blockNumber,
'status:',
l2Receipt?.status,
'hash:',
l2Receipt?.transactionHash,
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
create()
prepares and sends.wait(..., { for: 'l1' })
⇒ included on L1.wait(..., { for: 'l2' })
⇒ executed on L2 (funds available).
Inspect & customize (quote → prepare → create)
1. Quote (no side-effects)
Preview fees/steps and whether an approve is required.
const quote = await sdk.deposits.quote(params);
2. Prepare (build txs, don’t send)
Get TransactionRequest[]
for signing/UX.
const plan = await sdk.deposits.prepare(params);
3. Create (send) Use defaults, or send your prepared txs if you customized.
const handle = await sdk.deposits.create(params);
Track progress (status vs wait)
Non-blocking snapshot
const s = await sdk.deposits.status(handle /* or l1TxHash */);
// 'UNKNOWN' | 'L1_PENDING' | 'L1_INCLUDED' | 'L2_PENDING' | 'L2_EXECUTED' | 'L2_FAILED'
Block until checkpoint
const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });
Error handling patterns
Exceptions
try {
const handle = await sdk.deposits.create(params);
} catch (e) {
// normalized error envelope (type, operation, message, context, revert?)
}
No-throw style
Every method has a try*
variant (e.g. tryQuote
, tryPrepare
, tryCreate
).
These never throw—so you don’t need a 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.deposits.tryCreate(params);
if (!r.ok) {
// handle the error gracefully
console.error('Deposit failed:', r.error);
// maybe show a toast, retry, etc.
} else {
const handle = r.value;
console.log('Deposit sent. L1 tx hash:', handle.l1TxHash);
}
Troubleshooting
- Stuck at L1: check L1 gas and RPC health.
- No L2 execution: verify L2 RPC; re-check
status()
(should move toL2_EXECUTED
). - L2 failed:
status.phase === 'L2_FAILED'
→ inspect revert info via your error envelope/logs.
See also
Deposits (ethers)
A fast path to deposit ETH / ERC-20 from L1 → ZKsync (L2) using the ethers adapter.
Prerequisites
- A funded L1 account (gas + amount).
- RPC URLs:
L1_RPC_URL
,L2_RPC_URL
. - Installed:
@dutterbutter/zksync-sdk
+ethers
.
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 | L2 recipient address |
l2GasLimit | No | L2 execution gas cap |
gasPerPubdata | No | Pubdata price hint |
operatorTip | No | Optional tip to operator |
refundRecipient | No | L2 address to receive fee refunds |
ERC-20 deposits may require an L1
approve()
.quote()
surfaces required steps.
Fast path (one-shot)
// examples/deposit-eth.ts
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@dutterbutter/zksync-sdk/ethers';
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 .env file');
}
const l1 = new JsonRpcProvider(L1_RPC);
const l2 = new JsonRpcProvider(L2_RPC);
const signer = new Wallet(PRIVATE_KEY, l1);
const balance = await l1.getBalance(signer.address);
console.log('L1 balance:', balance.toString());
const balanceL2 = await l2.getBalance(signer.address);
console.log('L2 balance:', balanceL2.toString());
const client = await createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);
const me = (await signer.getAddress());
const params = {
amount: parseEther('.01'), // 0.01 ETH
to: me,
token: ETH_ADDRESS,
// optional:
// l2GasLimit: 300_000n,
// gasPerPubdata: 800n,
// operatorTip: 0n,
// refundRecipient: me,
} as const;
// Quote
const quote = await sdk.deposits.quote(params);
console.log('QUOTE response: ', quote);
const prepare = await sdk.deposits.prepare(params);
console.log('PREPARE response: ', prepare);
// Create (prepare + send)
const create = await sdk.deposits.create(params);
console.log('CREATE response: ', create);
const status = await sdk.deposits.status(create);
console.log('STATUS response: ', status);
// Wait (for now, L1 inclusion)
const receipt = await sdk.deposits.wait(create, { for: 'l1' });
console.log(
'Included at block:',
receipt?.blockNumber,
'status:',
receipt?.status,
'hash:',
receipt?.hash,
);
const status2 = await sdk.deposits.status(create);
console.log('STATUS2 response: ', status2);
// Wait (for now, L2 inclusion)
const l2Receipt = await sdk.deposits.wait(create, { for: 'l2' });
console.log(
'Included at block:',
l2Receipt?.blockNumber,
'status:',
l2Receipt?.status,
'hash:',
l2Receipt?.hash,
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
create()
prepares and sends.wait(..., { for: 'l1' })
⇒ included on L1.wait(..., { for: 'l2' })
⇒ executed on L2 (funds available).
Inspect & customize (quote → prepare → create)
1. Quote (no side-effects) Preview fees/steps and whether an approve is required.
const quote = await sdk.deposits.quote(params);
2. Prepare (build txs, don’t send)
Get TransactionRequest[]
for signing/UX.
const plan = await sdk.deposits.prepare(params);
3. Create (send) Use defaults, or send your prepared txs if you customized.
const handle = await sdk.deposits.create(params);
Track progress (status vs wait)
Non-blocking snapshot
const s = await sdk.deposits.status(handle /* or l1TxHash */);
// 'UNKNOWN' | 'L1_PENDING' | 'L1_INCLUDED' | 'L2_PENDING' | 'L2_EXECUTED' | 'L2_FAILED'
Block until checkpoint
const l1Receipt = await sdk.deposits.wait(handle, { for: 'l1' });
const l2Receipt = await sdk.deposits.wait(handle, { for: 'l2' });
Error handling patterns
Exceptions
try {
const handle = await sdk.deposits.create(params);
} catch (e) {
// normalized error envelope (type, operation, message, context, revert?)
}
No-throw style
Every method has a try*
variant (e.g. tryQuote
, tryPrepare
, tryCreate
).
These never throw—so you don’t need a 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.deposits.tryCreate(params);
if (!r.ok) {
// handle the error gracefully
console.error('Deposit failed:', r.error);
// maybe show a toast, retry, etc.
} else {
const handle = r.value;
console.log('Deposit sent. L1 tx hash:', handle.l1TxHash);
}
Troubleshooting
- Stuck at L1: check L1 gas and RPC health.
- No L2 execution: verify L2 RPC; re-check
status()
(should move toL2_EXECUTED
). - L2 failed:
status.phase === 'L2_FAILED'
→ inspect revert info via your error envelope/logs.
See also
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.
See also
Withdrawals (ethers)
A fast path to withdraw ETH / ERC-20 from ZKsync (L2) → Ethereum (L1) using the ethers 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
+ethers
.
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/withdrawals-eth.ts
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';
import { createEthersClient, createEthersSdk } from '@dutterbutter/zksync-sdk/ethers';
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() {
const l1 = new JsonRpcProvider(L1_RPC);
const l2 = new JsonRpcProvider(L2_RPC);
const signer = new Wallet(PRIVATE_KEY, l1);
const client = createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);
const me = (await signer.getAddress());
// Withdraw params (ETH)
const params = {
token: ETH_ADDRESS,
amount: parseEther('0.01'), // 0.001 ETH
to: me,
// l2GasLimit: 300_000n,
} as const;
// Quote (dry-run only)
const quote = await sdk.withdrawals.quote(params);
console.log('QUOTE: ', quote);
const prepare = await sdk.withdrawals.prepare(params);
console.log('PREPARE: ', prepare);
const created = await sdk.withdrawals.create(params);
console.log('CREATE:', created);
// Quick status check
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?.hash,
);
// Optional: check status again
console.log('STATUS (post-L2):', await sdk.withdrawals.status(created.l2TxHash));
// finalize on L1
// Use tryFinalize to avoid throwing in an example script
await sdk.withdrawals.wait(created.l2TxHash, { for: 'ready' });
console.log('STATUS (ready):', await sdk.withdrawals.status(created.l2TxHash));
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.hash);
} else {
console.log('Finalized (no local L1 receipt available, possibly finalized by another actor).');
}
}
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)
const quote = await sdk.withdrawals.quote(params);
2. Prepare (build txs, don’t send)
const plan = await sdk.withdrawals.prepare(params);
3. Create (send)
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' });
Finalization (required step)
const result = await sdk.withdrawals.finalize(handle.l2TxHash);
console.log('Finalization result:', result);
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
Use try*
methods to avoid exceptions. They return { ok, value }
or { ok, error }
.
Perfect for UIs or services that prefer explicit flow control.
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.
- Finalize reverts: ensure enough L1 gas; inspect revert info.
- Finalized but no receipt:
wait(..., { for: 'finalized' })
may returnnull
; retry or rely onfinalize()
result.
See also
Concepts
This section explains the small set of ideas you need to use the SDK confidently. Keep these in mind as you read the guides and API reference.
What’s here
-
Status vs Wait
When to take a quick, non-blocking snapshot (status
) vs when to block until a checkpoint (wait
).
Covers deposit phases (L1_PENDING → L2_EXECUTED/FAILED
) and withdrawal phases (L2_PENDING → READY_TO_FINALIZE → FINALIZED
), polling options, and return shapes. -
Finalization
Withdrawals are two-step: initiate on L2, then you must callfinalize
on L1 to release funds.
Explains readiness, how to detectREADY_TO_FINALIZE
, and how to usefinalize
.
Status vs Wait
The SDK exposes two complementary ways to track progress:
status(...)
— returns a non-blocking snapshot of where an operation is.wait(..., { for })
— blocks/polls until a specified checkpoint is reached.
Both methods work for Deposits and Withdrawals, but Withdrawals add finalization-specific states and targets.
Withdrawals
withdrawals.status(h | l2TxHash) → Promise<WithdrawalStatus>
Input
h
: aWithdrawalWaitable
(e.g., fromcreate
) or the L2 tx hashHex
.
Phases returned
UNKNOWN
— no L2 hash available on the handle.L2_PENDING
— L2 tx not yet included.PENDING
— L2 included, but not yet ready to finalize.READY_TO_FINALIZE
— finalization would succeed now.FINALIZED
— finalized on L1 (funds released).
Notes
- When L2 receipt is missing →
L2_PENDING
. - When finalization key can be derived but not ready →
PENDING
. - When already finalized →
FINALIZED
.
Example
const s = await sdk.withdrawals.status(handleOrHash);
// s.phase in: 'UNKNOWN' | 'L2_PENDING' | 'PENDING' | 'READY_TO_FINALIZE' | 'FINALIZED'
withdrawals.wait(h | l2TxHash, { for, pollMs?, timeoutMs? })
Targets
{ for: 'l2' }
→ resolves with L2 receipt (TransactionReceiptZKsyncOS | null
){ for: 'ready' }
→ resolvesnull
when finalization becomes possible{ for: 'finalized' }
→ resolves L1 receipt when finalized, ornull
if finalized but receipt not found
Behavior
- If the handle has no L2 tx hash, returns
null
immediately. - Default polling interval: 5500ms default or set explicitly if you want.
- Optional
timeoutMs
returnsnull
on deadline.
Example
// wait for inclusion on L2, get L2 receipt (augmented with l2ToL1Logs if available)
const l2Rcpt = await sdk.withdrawals.wait(handle, { for: 'l2', pollMs: 5000 });
// wait until it's available to finalize (no side-effects)
await sdk.withdrawals.wait(handle, { for: 'ready' });
// wait until finalized; returns L1 receipt (or null if finalized but receipt not retrievable)
const l1Rcpt = await sdk.withdrawals.wait(handle, { for: 'finalized' });
Common Troubleshooting
- Network hiccup while fetching receipts → thrown
ZKsyncError
(RPC
kind). - Internal decode issue → thrown
ZKsyncError
(INTERNAL
kind).
Deposits
deposits.status(h | l1TxHash) → Promise<DepositStatus>
Input
h
:DepositWaitable
(fromcreate
) or L1 tx hashHex
.
Phases returned
UNKNOWN
— no L1 hash.L1_PENDING
— L1 receipt missing.L1_INCLUDED
— L1 included, but L2 hash not yet derivable from logs.L2_PENDING
— L2 hash known but receipt missing.L2_EXECUTED
— L2 receipt present withstatus === 1
.L2_FAILED
— L2 receipt present withstatus !== 1
.
Example
const s = await sdk.deposits.status(handleOrL1Hash);
// s.phase in: 'UNKNOWN' | 'L1_PENDING' | 'L1_INCLUDED' | 'L2_PENDING' | 'L2_EXECUTED' | 'L2_FAILED'
deposits.wait(h | l1TxHash, { for: 'l1' | 'l2' })
Targets
{ for: 'l1' }
→ waits for L1 inclusion → L1 receipt ornull
{ for: 'l2' }
→ waits L1 inclusion and canonical L2 execution → L2 receipt ornull
Example
const l1Rcpt = await sdk.deposits.wait(handle, { for: 'l1' });
const l2Rcpt = await sdk.deposits.wait(handle, { for: 'l2' });
Tips & edge cases
- Handles vs hashes: Both methods accept either a handle (from
create
) or a raw tx hash (Hex
). If you pass a handle without the relevant hash, you’ll getUNKNOWN
/null
. - Polling: For withdrawals, set
pollMs
explicitly if you want tighter/looser polling; minimum enforced is 5500ms. - Timeouts: Use
timeoutMs
for long waits (e.g., finalization windows) to avoid hanging scripts.
Finalization (Withdrawals)
When withdrawing from ZKsync (L2) back to Ethereum (L1), your funds are not automatically released on L1 once the L2 transaction is included.
Withdrawals are always a two-step process:
-
Initiate on L2 — you call
withdraw()
(via the SDK’screate
) to start the withdrawal.- This burns/locks the funds on L2.
- At this point, your withdrawal is visible in L2 receipts and logs, but your funds are not yet available on L1.
-
Finalize on L1 — you must explicitly call
finalize
to release your funds on L1.- This submits an L1 transaction.
- Only after this step does your ETH or token balance increase on Ethereum.
Why finalization matters
- Funds remain locked until finalization.
- Anyone can finalize — not just the withdrawer. In practice, most users will finalize their own withdrawals.
- Finalization costs gas on L1, so plan for this when withdrawing.
If you forget to finalize, your funds will stay in limbo: visible as “ready to withdraw,” but unavailable on Ethereum.
SDK methods
finalize(l2TxHash)
Actively sends the L1 transaction to finalize the withdrawal. Returns the updatedstatus
and the L1 receipt.
Example: Explicit finalize
// Step 1: Create withdrawal on L2
const withdrawal = await sdk.withdrawals.create({
token: ETH_ADDRESS,
amount: parseEther('0.1'),
to: myAddress,
});
// Step 2: Finalize on L1
await sdk.withdrawals.wait(withdrawal, { for: 'ready' }); // block until finalizable
const { status, receipt } = await sdk.withdrawals.finalize(withdrawal.l2TxHash);
console.log(status.phase); // "FINALIZED"
console.log(receipt?.transactionHash); // L1 finalize tx
Reference
This section documents the low-level APIs exposed by the SDK.
Unlike the high-level flows (deposits
, withdrawals
), these helpers give you direct access to ZKsync-specific contracts and RPC methods.
What you’ll find here
-
ZKsync RPC Helpers
A typed interface around ZKsynczks_
JSON-RPC methods such as:getBridgehubAddress()
getL2ToL1LogProof()
getReceiptWithL2ToL1()
getGenesis()
-
Common Helpers
Utility getters for frequently used contracts and addresses, such as:l1AssetRouter()
,l1Nullifier()
,l1NativeTokenVault()
baseToken(chainId)
l1TokenAddress(l2Token)
,l2TokenAddress(l1Token)
assetId(l1Token)
ZKsync zks_
RPC Helpers
These helpers expose ZKsync-specific RPC methods through the SDK’s client.
They work the same whether you’re using the viem or ethers adapter.
In all examples below, assume you’ve already created a
client
(viacreateViemClient
orcreateEthersClient
).
Calls are identical across adapters:client.zks.*
.
getBridgehubAddress()
What it does
Returns the canonical Bridgehub contract address.
Example
const bridgehub = await client.zks.getBridgehubAddress();
console.log('Bridgehub:', bridgehub); // 0x...
Returns
Address
(EVM address string, 0x…
)
getGenesis()
What it does
Retrieves the L2 genesis configuration exposed by the node, including initial contract deployments, storage patches, execution version, and the expected genesis root.
Example
const genesis = await client.zks.getGenesis();
for (const contract of genesis.initialContracts) {
console.log('Contract at', contract.address, 'with bytecode', contract.bytecode);
}
console.log('Execution version:', genesis.executionVersion);
console.log('Genesis root:', genesis.genesisRoot);
Returns
type GenesisInput = {
initialContracts: {
address: Address;
bytecode: `0x${string}`;
}[];
additionalStorage: {
key: `0x${string}`;
value: `0x${string}`;
}[];
executionVersion: number;
genesisRoot: `0x${string}`;
};
getReceiptWithL2ToL1(txHash)
What it does
Fetches the transaction receipt for an L2 tx and includes l2ToL1Logs
as an array.
This makes it easy to locate L2→L1 messages without guessing the shape.
Example
const l2TxHash = '0x...'; // L2 transaction hash
const receipt = await client.zks.getReceiptWithL2ToL1(l2TxHash);
if (!receipt) {
console.log('Receipt not found yet');
} else {
console.log('l2ToL1Logs count:', receipt.l2ToL1Logs.length);
// e.g. find the first L1MessageSent-like entry here if you need raw data
}
Returns
ReceiptWithL2ToL1 | null
- Same fields as a normal receipt, plus
l2ToL1Logs: any[]
(always present; empty if none). null
when the node does not yet have the receipt.
getL2ToL1LogProof(txHash, index)
What it does
Fetches the proof for an L2→L1 log at a given index
in the transaction’s messenger logs.
The SDK normalizes the response to a consistent shape.
Example
const l2TxHash = '0x...';
const messengerLogIndex = 0; // whichever log index you intend to finalize
try {
const proof = await client.zks.getL2ToL1LogProof(l2TxHash, messengerLogIndex);
// proof.id, proof.batchNumber, proof.proof (Hex[])
console.log('Proof id:', proof.id.toString());
console.log('Batch number:', proof.batchNumber.toString());
console.log('Proof length:', proof.proof.length);
} catch (e) {
// If the proof is not yet available, the SDK raises a STATE error with a clear message.
console.error('Proof unavailable yet or RPC error:', e);
}
Returns
type ProofNormalized = {
id: bigint;
batchNumber: bigint;
proof: `0x${string}`[];
};
Common Helpers
Convenience APIs for addresses, contracts, and token mapping.
Available under sdk.helpers
(ethers
and viem
adapter).
Addresses
const addresses = await sdk.helpers.addresses();
console.log(addresses.bridgehub);
Resolves and caches core contract addresses (Bridgehub, routers, vaults, core contracts).
Call client.refresh()
to clear the cache if networks/overrides change.
Contracts
const contracts = await sdk.helpers.contracts();
console.log(await contracts.l1AssetRouter.paused());
Returns connected ethers.Contract
instances (viem
equivalents) for all core contracts.
You can also call individual shortcuts:
const router = await sdk.helpers.l1AssetRouter();
const vault = await sdk.helpers.l1NativeTokenVault();
const nullifier = await sdk.helpers.l1Nullifier();
Base Token
const base = await sdk.helpers.baseToken();
const baseOther = await sdk.helpers.baseToken(BigInt(300));
Reads the base token for the current L2 network, or a specific chain id.
Token Mapping
L1 → L2
import { ETH_ADDRESS } from '@dutterbutter/zksync-sdk/core';
const l2Eth = await sdk.helpers.l2TokenAddress(ETH_ADDRESS);
const l2Usdc = await sdk.helpers.l2TokenAddress('0x...');
- ETH maps to the special ETH placeholder on L2.
- If the L1 token is the base token, you get the L2 base-token system address.
L2 → L1
const l1Token = await sdk.helpers.l1TokenAddress('0x...L2Token');
Maps an L2 token back to its L1 token.
Asset ID
import { ETH_ADDRESS } from '@dutterbutter/zksync-sdk/core';
const ethId = await sdk.helpers.assetId(ETH_ADDRESS);
const tokenId = await sdk.helpers.assetId('0x...');
Fetches the assetId (bytes32) for a token. ETH is handled automatically.
Behavior & Notes
- Caching:
addresses()
andcontracts()
results are cached; useclient.refresh()
to reset.