Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/snippets/ethers/withdrawals-erc20.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ async function main() {
const sdk = createEthersSdk(client);

const me = (await signer.getAddress());
const l2Token = await sdk.helpers.l2TokenAddress(L1_ERC20_TOKEN);
const l2Token = await sdk.tokens.toL2Address(L1_ERC20_TOKEN);

// Prepare withdraw params
const params = {
Expand Down
1 change: 1 addition & 0 deletions docs/src/sdk-reference/ethers/deposits.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ L1 β†’ L2 deposits for ETH and ERC-20 tokens with quote, prepare, create, status
* **Typical flow:** `quote β†’ create β†’ wait({ for: 'l2' })`
* **Auto-routing:** ETH vs ERC-20 and base-token vs non-base handled automatically
* **Error style:** Throwing methods (`quote`, `prepare`, `create`, `wait`) + safe variants (`tryQuote`, `tryPrepare`, `tryCreate`, `tryWait`)
* **Token mapping:** Use `sdk.tokens` for L1⇄L2 token lookups and assetIds before calling into deposits if you need token metadata.

## Import

Expand Down
40 changes: 10 additions & 30 deletions docs/src/sdk-reference/ethers/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ High-level SDK built on top of the **Ethers adapter** β€” provides deposits, wit
## At a Glance

* **Factory:** `createEthersSdk(client) β†’ EthersSdk`
* **Composed resources:** `sdk.deposits`, `sdk.withdrawals`, `sdk.helpers`
* **Composed resources:** `sdk.deposits`, `sdk.withdrawals`, `sdk.tokens`, `sdk.helpers`
* **Client vs SDK:** The **client** wires RPC/signing, while the **SDK** adds high-level flows (`quote β†’ prepare β†’ create β†’ wait`) and convenience helpers.

## Import
Expand Down Expand Up @@ -40,6 +40,10 @@ await sdk.deposits.wait(handle, { for: 'l2' });

// Example: resolve core contracts
const { l1NativeTokenVault } = await sdk.helpers.contracts();

// Example: map a token L1 β†’ L2
const token = await sdk.tokens.resolve('0xYourToken');
console.log(token.l2);
```

> [!TIP]
Expand Down Expand Up @@ -67,6 +71,11 @@ See [Deposits](./deposits.md).
L2 β†’ L1 flows.
See [Withdrawals](./withdrawals.md).

### `tokens: TokensResource`

Token identity, L1⇄L2 mapping, bridge asset IDs, chain token facts.
See [Tokens](./tokens.md).

## `helpers`

Utilities for chain addresses, connected contracts, and L1↔L2 token mapping.
Expand Down Expand Up @@ -107,35 +116,6 @@ L1 address of the **base token** for the current (or provided) L2 chain.
const base = await sdk.helpers.baseToken(); // infers from client.l2
```

### `l2TokenAddress(l1Token: Address) β†’ Promise<Address>`

Return the **L2 token address** for a given L1 token.

* Handles ETH special case (L2 ETH placeholder).
* If token is the chain’s base token, returns the L2 base-token system address.
* Otherwise queries `IL2NativeTokenVault.l2TokenAddress`.

```ts
const l2Crown = await sdk.helpers.l2TokenAddress(CROWN_ERC20_ADDRESS);
```

### `l1TokenAddress(l2Token: Address) β†’ Promise<Address>`

Return the **L1 token** corresponding to an L2 token via `IL2AssetRouter.l1TokenAddress`.
ETH placeholder resolves to canonical ETH.

```ts
const l1Crown = await sdk.helpers.l1TokenAddress(L2_CROWN_ADDRESS);
```

### `assetId(l1Token: Address) β†’ Promise<Hex>`

Get the `bytes32` asset ID via `L1NativeTokenVault.assetId` (handles ETH canonically).

```ts
const id = await sdk.helpers.assetId(CROWN_ERC20_ADDRESS);
```

## Notes & Pitfalls

* **Client first:**
Expand Down
102 changes: 102 additions & 0 deletions docs/src/sdk-reference/ethers/tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Tokens

Token identity, L1↔L2 mapping, bridge asset IDs, and chain token facts for ETH, base token, and ERC-20s.

---

## At a Glance

* **Resource:** `sdk.tokens`
* **Capabilities:** resolve tokens, map L1⇄L2 addresses, compute `assetId`, detect base token, WETH helpers, compute bridged addresses.
* **Auto-handling:** ETH aliases (`ETH_ADDRESS`, `FORMAL_ETH_ADDRESS`) and L2 base-token alias are normalized for you.
* **Error style:** Throwing methods (`resolve`, `toL1Address`, etc.); wrap in try/catch or use upstream result-handling.

## Import

```ts
import { JsonRpcProvider, Wallet } from 'ethers';
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';

const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);

const client = createEthersClient({ l1, l2, signer });
const sdk = createEthersSdk(client);
// sdk.tokens β†’ TokensResource
```

## Quick Start

Resolve a token by L1 address and fetch its L2 counterpart + bridge metadata:

```ts
const token = await sdk.tokens.resolve('0xYourTokenL1...');
/*
{
kind: 'eth' | 'base' | 'erc20',
l1: Address,
l2: Address,
assetId: Hex,
originChainId: bigint,
isChainEthBased: boolean,
baseTokenAssetId: Hex,
wethL1: Address,
wethL2: Address,
}
*/
```

Map addresses directly:

```ts
const l2Addr = await sdk.tokens.toL2Address('0xTokenL1...');
const l1Addr = await sdk.tokens.toL1Address(l2Addr);
```

Compute bridge identifiers:

```ts
const assetId = await sdk.tokens.assetIdOfL1('0xTokenL1...');
const backL2 = await sdk.tokens.l2TokenFromAssetId(assetId);
```

## Method Reference

### `resolve(ref: Address | TokenRef, opts?: { chain?: 'l1' | 'l2' }) β†’ Promise<ResolvedToken>`

Resolve a token reference into full metadata (kind, addresses, assetId, chain facts).

> [!NOTE]
> Pass `opts.chain: 'l2'` when providing an L2 token address; defaults to `'l1'`.

### L1↔L2 Mapping

* `toL2Address(l1Token: Address) β†’ Promise<Address>` β€” returns L2 token; base token β†’ `L2_BASE_TOKEN_ADDRESS`, ETH aliases normalized.
* `toL1Address(l2Token: Address) β†’ Promise<Address>` β€” returns L1 token; ETH alias normalized.

### Bridge Identity

* `assetIdOfL1(l1Token: Address) β†’ Promise<Hex>`
* `assetIdOfL2(l2Token: Address) β†’ Promise<Hex>`
* `l2TokenFromAssetId(assetId: Hex) β†’ Promise<Address>`
* `l1TokenFromAssetId(assetId: Hex) β†’ Promise<Address>`
* `originChainId(assetId: Hex) β†’ Promise<bigint>`

### Chain Token Facts

* `baseTokenAssetId() β†’ Promise<Hex>` β€” cached.
* `isChainEthBased() β†’ Promise<boolean>` β€” compares base token assetId vs ETH assetId.
* `wethL1() β†’ Promise<Address>` β€” cached WETH on L1.
* `wethL2() β†’ Promise<Address>` β€” cached WETH on L2.

### Address Compute

* `computeL2BridgedAddress({ originChainId, l1Token }) β†’ Promise<Address>` β€” deterministic CREATE2 address for a bridged token; handles ETH alias normalization.

## Notes & Pitfalls

* **Caching:** `baseTokenAssetId`, `wethL1`, `wethL2`, and the origin chain id are memoized; repeated calls avoid extra RPC hits.
* **ETH aliases:** Both `0xEeeee…` (ETH sentinel) and `FORMAL_ETH_ADDRESS` are normalized to canonical ETH.
* **Base token alias:** `L2_BASE_TOKEN_ADDRESS` maps back to the L1 base token via `toL1Address`.
* **Error handling:** Methods throw typed errors via the adapters’ error handlers. Wrap with `try/catch` or rely on higher-level `try*` patterns.
1 change: 1 addition & 0 deletions docs/src/sdk-reference/ethers/withdrawals.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ L2 β†’ L1 withdrawals for ETH and ERC-20 tokens with quote, prepare, create, sta
* **Typical flow:** `quote β†’ create β†’ wait({ for: 'l2' }) β†’ wait({ for: 'ready' }) β†’ finalize`
* **Auto-routing:** ETH vs ERC-20 and base-token vs non-base handled internally
* **Error style:** Throwing methods (`quote`, `prepare`, `create`, `status`, `wait`, `finalize`) + safe variants (`tryQuote`, `tryPrepare`, `tryCreate`, `tryWait`, `tryFinalize`)
* **Token mapping:** Use `sdk.tokens` if you need L1/L2 token addresses or assetIds ahead of time.

## Import

Expand Down
6 changes: 3 additions & 3 deletions examples/ethers/withdrawals/erc20-nonbase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
* Example: Withdraw a non-base ERC-20 from L2
*
* Notes:
* - Pass the L1 token address to `sdk.helpers.l2TokenAddress` to discover its L2 counterpart.
* - Pass the L1 token address to `sdk.tokens.toL2Address` to discover its L2 counterpart.
* - Route: `erc20-nonbase` β†’ NTV + L2AssetRouter.withdraw(assetId, assetData).
* - SDK will add an L2 approval step (spender = L2NativeTokenVault) if needed.
*
* Flow:
* 1) Connect to L1 + L2 RPCs and create Ethers SDK client.
* 2) Resolve the L2 token address using `sdk.helpers.l2TokenAddress(L1_TOKEN)`.
* 2) Resolve the L2 token address using `sdk.tokens.toL2Address(L1_TOKEN)`.
* 3) Inspect balances/symbol/decimals.
* 4) Call `sdk.withdrawals.quote` or `prepare` (optional).
* 5) Call `sdk.withdrawals.create` (approve first if needed, then withdraw).
Expand Down Expand Up @@ -42,7 +42,7 @@ async function main() {
const me = (await signer.getAddress()) as Address;

// Discover the corresponding L2 token for this L1 token
const l2Token = await sdk.helpers.l2TokenAddress(L1_ERC20_TOKEN);
const l2Token = await sdk.tokens.toL2Address(L1_ERC20_TOKEN);

const erc20L1 = new Contract(L1_ERC20_TOKEN, IERC20ABI, l1);
const erc20L2 = new Contract(l2Token, IERC20ABI, l2);
Expand Down
118 changes: 118 additions & 0 deletions src/adapters/__tests__/tokens-ethers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'bun:test';
import { Interface, AbiCoder, ethers } from 'ethers';

import { createTokensResource } from '../ethers/resources/tokens';
import { createEthersHarness, ADAPTER_TEST_ADDRESSES } from './adapter-harness';
import {
ETH_ADDRESS,
FORMAL_ETH_ADDRESS,
L2_ASSET_ROUTER_ADDRESS,
L2_BASE_TOKEN_ADDRESS,
L2_NATIVE_TOKEN_VAULT_ADDRESS,
} from '../../core/constants';
import { IL2AssetRouterABI, L1NativeTokenVaultABI, L2NativeTokenVaultABI } from '../../core/abi';
import { createNTVCodec } from '../../core/codec/ntv';

const L1NTV = new Interface(L1NativeTokenVaultABI as any);
const L2NTV = new Interface(L2NativeTokenVaultABI as any);
const L2AR = new Interface(IL2AssetRouterABI as any);

const ntvCodec = createNTVCodec({
encode: (types, values) => AbiCoder.defaultAbiCoder().encode(types, values) as `0x${string}`,
keccak256: (data: `0x${string}`) => ethers.keccak256(data) as `0x${string}`,
});

describe('adapters/tokens (ethers)', () => {
it('resolves a non-base ERC20 with L1/L2 mapping and facts', async () => {
const harness = createEthersHarness();
const tokens = createTokensResource(harness.client);

const l1Token = '0x0000000000000000000000000000000000000111' as const;
const l2Token = '0x0000000000000000000000000000000000000222' as const;
const assetId = '0xaaa0000000000000000000000000000000000000000000000000000000000001' as const;
const baseTokenAssetId =
'0xbbb0000000000000000000000000000000000000000000000000000000000002' as const;

// Base chain facts
harness.registry.set(ADAPTER_TEST_ADDRESSES.l1NativeTokenVault, L1NTV, 'assetId', assetId, [
l1Token,
]);
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'assetId', assetId, [l2Token]);
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'tokenAddress', l2Token, [assetId]);
harness.registry.set(
ADAPTER_TEST_ADDRESSES.l1NativeTokenVault,
L1NTV,
'tokenAddress',
l1Token,
[assetId],
);
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'originChainId', 9n, [assetId]);
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'l2TokenAddress', l2Token, [
l1Token,
]);
harness.registry.set(L2_ASSET_ROUTER_ADDRESS, L2AR, 'l1TokenAddress', l1Token, [l2Token]);
harness.registry.set(
L2_NATIVE_TOKEN_VAULT_ADDRESS,
L2NTV,
'BASE_TOKEN_ASSET_ID',
baseTokenAssetId,
);
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'L1_CHAIN_ID', 1n);
harness.registry.set(
ADAPTER_TEST_ADDRESSES.l1NativeTokenVault,
L1NTV,
'WETH_TOKEN',
ADAPTER_TEST_ADDRESSES.baseTokenFor324,
);
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'WETH_TOKEN', L2_BASE_TOKEN_ADDRESS);

const resolved = await tokens.resolve(l1Token);
expect(resolved.kind).toBe('erc20');
expect(resolved.l1.toLowerCase()).toBe(l1Token.toLowerCase());
expect(resolved.l2.toLowerCase()).toBe(l2Token.toLowerCase());
expect(resolved.assetId.toLowerCase()).toBe(assetId.toLowerCase());
expect(resolved.baseTokenAssetId.toLowerCase()).toBe(baseTokenAssetId.toLowerCase());
expect(resolved.originChainId).toBe(9n);
expect(resolved.isChainEthBased).toBe(false);
expect(resolved.wethL1.toLowerCase()).toBe(
ADAPTER_TEST_ADDRESSES.baseTokenFor324.toLowerCase(),
);
expect(resolved.wethL2.toLowerCase()).toBe(L2_BASE_TOKEN_ADDRESS.toLowerCase());
});

it('detects ETH-based chains via baseTokenAssetId', async () => {
const harness = createEthersHarness();
const tokens = createTokensResource(harness.client);

const ethAssetId = ntvCodec.encodeAssetId(1n, L2_NATIVE_TOKEN_VAULT_ADDRESS, ETH_ADDRESS);
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'BASE_TOKEN_ASSET_ID', ethAssetId);
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'L1_CHAIN_ID', 1n);

const isEthBased = await tokens.isChainEthBased();
expect(isEthBased).toBe(true);
});

it('normalizes ETH aliases for CREATE2 predictions and base-token alias mapping', async () => {
const harness = createEthersHarness();
const tokens = createTokensResource(harness.client);

const predicted = '0x0000000000000000000000000000000000000c0d' as const;
harness.registry.set(
L2_NATIVE_TOKEN_VAULT_ADDRESS,
L2NTV,
'calculateCreate2TokenAddress',
predicted,
[1n, ETH_ADDRESS],
);

const computed = await tokens.computeL2BridgedAddress({
originChainId: 1n,
l1Token: FORMAL_ETH_ADDRESS,
});
expect(computed.toLowerCase()).toBe(predicted.toLowerCase());

// Base-token alias should map back to L1 base token for the chain
const baseL1 = await tokens.toL1Address(L2_BASE_TOKEN_ADDRESS);
expect(baseL1.toLowerCase()).toBe(ADAPTER_TEST_ADDRESSES.baseTokenFor324.toLowerCase());
});
});
15 changes: 0 additions & 15 deletions src/adapters/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,6 @@ const SAMPLE = {
};

describe('adapters/utils β€” encoding parity', () => {
it('encodeNativeTokenVaultAssetId matches between ethers & viem implementations', () => {
const ethersEncoded = ethersUtils.encodeNativeTokenVaultAssetId(SAMPLE.chainId, SAMPLE.token);
const viemEncoded = viemUtils
.encodeNativeTokenVaultAssetId(SAMPLE.chainId, SAMPLE.token)
.toLowerCase();

expect(ethersEncoded.toLowerCase()).toBe(viemEncoded);
expect(ethersUtils.encodeNTVAssetId(SAMPLE.chainId, SAMPLE.token).toLowerCase()).toBe(
ethersEncoded.toLowerCase(),
);
});

it('encodeNativeTokenVaultTransferData encodes amount/receiver/token identically', () => {
const ethersEncoded = ethersUtils.encodeNativeTokenVaultTransferData(
SAMPLE.amount,
Expand All @@ -48,9 +36,6 @@ describe('adapters/utils β€” encoding parity', () => {
expect(BigInt(amount.toString())).toBe(SAMPLE.amount);
expect((receiver as string).toLowerCase()).toBe(SAMPLE.receiver.toLowerCase());
expect((token as string).toLowerCase()).toBe(SAMPLE.token.toLowerCase());
expect(ethersUtils.encodeNTVTransferData(SAMPLE.amount, SAMPLE.receiver, SAMPLE.token)).toBe(
ethersEncoded,
);
});

it('encodeSecondBridgeDataV1 prefixes 0x01 and encodes payload equally', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/ethers/e2e/erc20.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('e2e (ethers): ERC-20 deposit L1->L2 and withdraw L2->L1', () => {
}, 180_000);

it('deposits: should reflect L2 token credit (resolve L2 token and check deltas)', async () => {
const resolved = await sdk.helpers.l2TokenAddress(l1TokenAddr);
const resolved = await sdk.tokens.toL2Address(l1TokenAddr);
expect(resolved).toMatch(/^0x[0-9a-fA-F]{40}$/);
l2TokenAddr = resolved as Address;

Expand Down
Loading