Skip to content

Commit 09ee772

Browse files
authored
feat: add token resource for quick l1-l2 mapping, assetId querying, chain info (#22)
* feat: adds token resource * chore: use sdk.token resource in favour of token-info. * fix: address address issue * chore: address lint issues * feat: adds token resource with simplified unit tests * feat: add token resource docs * feat: add token resource docs * chore: fix broken import * chore: update docs
1 parent b4237f7 commit 09ee772

File tree

29 files changed

+1011
-364
lines changed

29 files changed

+1011
-364
lines changed

docs/snippets/ethers/withdrawals-erc20.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ async function main() {
1818
const sdk = createEthersSdk(client);
1919

2020
const me = (await signer.getAddress());
21-
const l2Token = await sdk.helpers.l2TokenAddress(L1_ERC20_TOKEN);
21+
const l2Token = await sdk.tokens.toL2Address(L1_ERC20_TOKEN);
2222

2323
// Prepare withdraw params
2424
const params = {

docs/src/sdk-reference/ethers/deposits.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ L1 → L2 deposits for ETH and ERC-20 tokens with quote, prepare, create, status
1010
* **Typical flow:** `quote → create → wait({ for: 'l2' })`
1111
* **Auto-routing:** ETH vs ERC-20 and base-token vs non-base handled automatically
1212
* **Error style:** Throwing methods (`quote`, `prepare`, `create`, `wait`) + safe variants (`tryQuote`, `tryPrepare`, `tryCreate`, `tryWait`)
13+
* **Token mapping:** Use `sdk.tokens` for L1⇄L2 token lookups and assetIds before calling into deposits if you need token metadata.
1314

1415
## Import
1516

docs/src/sdk-reference/ethers/sdk.md

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ High-level SDK built on top of the **Ethers adapter** — provides deposits, wit
77
## At a Glance
88

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

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

4141
// Example: resolve core contracts
4242
const { l1NativeTokenVault } = await sdk.helpers.contracts();
43+
44+
// Example: map a token L1 → L2
45+
const token = await sdk.tokens.resolve('0xYourToken');
46+
console.log(token.l2);
4347
```
4448

4549
> [!TIP]
@@ -67,6 +71,11 @@ See [Deposits](./deposits.md).
6771
L2 → L1 flows.
6872
See [Withdrawals](./withdrawals.md).
6973

74+
### `tokens: TokensResource`
75+
76+
Token identity, L1⇄L2 mapping, bridge asset IDs, chain token facts.
77+
See [Tokens](./tokens.md).
78+
7079
## `helpers`
7180

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

110-
### `l2TokenAddress(l1Token: Address) → Promise<Address>`
111-
112-
Return the **L2 token address** for a given L1 token.
113-
114-
* Handles ETH special case (L2 ETH placeholder).
115-
* If token is the chain’s base token, returns the L2 base-token system address.
116-
* Otherwise queries `IL2NativeTokenVault.l2TokenAddress`.
117-
118-
```ts
119-
const l2Crown = await sdk.helpers.l2TokenAddress(CROWN_ERC20_ADDRESS);
120-
```
121-
122-
### `l1TokenAddress(l2Token: Address) → Promise<Address>`
123-
124-
Return the **L1 token** corresponding to an L2 token via `IL2AssetRouter.l1TokenAddress`.
125-
ETH placeholder resolves to canonical ETH.
126-
127-
```ts
128-
const l1Crown = await sdk.helpers.l1TokenAddress(L2_CROWN_ADDRESS);
129-
```
130-
131-
### `assetId(l1Token: Address) → Promise<Hex>`
132-
133-
Get the `bytes32` asset ID via `L1NativeTokenVault.assetId` (handles ETH canonically).
134-
135-
```ts
136-
const id = await sdk.helpers.assetId(CROWN_ERC20_ADDRESS);
137-
```
138-
139119
## Notes & Pitfalls
140120

141121
* **Client first:**
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Tokens
2+
3+
Token identity, L1↔L2 mapping, bridge asset IDs, and chain token facts for ETH, base token, and ERC-20s.
4+
5+
---
6+
7+
## At a Glance
8+
9+
* **Resource:** `sdk.tokens`
10+
* **Capabilities:** resolve tokens, map L1⇄L2 addresses, compute `assetId`, detect base token, WETH helpers, compute bridged addresses.
11+
* **Auto-handling:** ETH aliases (`ETH_ADDRESS`, `FORMAL_ETH_ADDRESS`) and L2 base-token alias are normalized for you.
12+
* **Error style:** Throwing methods (`resolve`, `toL1Address`, etc.); wrap in try/catch or use upstream result-handling.
13+
14+
## Import
15+
16+
```ts
17+
import { JsonRpcProvider, Wallet } from 'ethers';
18+
import { createEthersClient, createEthersSdk } from '@matterlabs/zksync-js/ethers';
19+
20+
const l1 = new JsonRpcProvider(process.env.ETH_RPC!);
21+
const l2 = new JsonRpcProvider(process.env.ZKSYNC_RPC!);
22+
const signer = new Wallet(process.env.PRIVATE_KEY!, l1);
23+
24+
const client = createEthersClient({ l1, l2, signer });
25+
const sdk = createEthersSdk(client);
26+
// sdk.tokens → TokensResource
27+
```
28+
29+
## Quick Start
30+
31+
Resolve a token by L1 address and fetch its L2 counterpart + bridge metadata:
32+
33+
```ts
34+
const token = await sdk.tokens.resolve('0xYourTokenL1...');
35+
/*
36+
{
37+
kind: 'eth' | 'base' | 'erc20',
38+
l1: Address,
39+
l2: Address,
40+
assetId: Hex,
41+
originChainId: bigint,
42+
isChainEthBased: boolean,
43+
baseTokenAssetId: Hex,
44+
wethL1: Address,
45+
wethL2: Address,
46+
}
47+
*/
48+
```
49+
50+
Map addresses directly:
51+
52+
```ts
53+
const l2Addr = await sdk.tokens.toL2Address('0xTokenL1...');
54+
const l1Addr = await sdk.tokens.toL1Address(l2Addr);
55+
```
56+
57+
Compute bridge identifiers:
58+
59+
```ts
60+
const assetId = await sdk.tokens.assetIdOfL1('0xTokenL1...');
61+
const backL2 = await sdk.tokens.l2TokenFromAssetId(assetId);
62+
```
63+
64+
## Method Reference
65+
66+
### `resolve(ref: Address | TokenRef, opts?: { chain?: 'l1' | 'l2' }) → Promise<ResolvedToken>`
67+
68+
Resolve a token reference into full metadata (kind, addresses, assetId, chain facts).
69+
70+
> [!NOTE]
71+
> Pass `opts.chain: 'l2'` when providing an L2 token address; defaults to `'l1'`.
72+
73+
### L1↔L2 Mapping
74+
75+
* `toL2Address(l1Token: Address) → Promise<Address>` — returns L2 token; base token → `L2_BASE_TOKEN_ADDRESS`, ETH aliases normalized.
76+
* `toL1Address(l2Token: Address) → Promise<Address>` — returns L1 token; ETH alias normalized.
77+
78+
### Bridge Identity
79+
80+
* `assetIdOfL1(l1Token: Address) → Promise<Hex>`
81+
* `assetIdOfL2(l2Token: Address) → Promise<Hex>`
82+
* `l2TokenFromAssetId(assetId: Hex) → Promise<Address>`
83+
* `l1TokenFromAssetId(assetId: Hex) → Promise<Address>`
84+
* `originChainId(assetId: Hex) → Promise<bigint>`
85+
86+
### Chain Token Facts
87+
88+
* `baseTokenAssetId() → Promise<Hex>` — cached.
89+
* `isChainEthBased() → Promise<boolean>` — compares base token assetId vs ETH assetId.
90+
* `wethL1() → Promise<Address>` — cached WETH on L1.
91+
* `wethL2() → Promise<Address>` — cached WETH on L2.
92+
93+
### Address Compute
94+
95+
* `computeL2BridgedAddress({ originChainId, l1Token }) → Promise<Address>` — deterministic CREATE2 address for a bridged token; handles ETH alias normalization.
96+
97+
## Notes & Pitfalls
98+
99+
* **Caching:** `baseTokenAssetId`, `wethL1`, `wethL2`, and the origin chain id are memoized; repeated calls avoid extra RPC hits.
100+
* **ETH aliases:** Both `0xEeeee…` (ETH sentinel) and `FORMAL_ETH_ADDRESS` are normalized to canonical ETH.
101+
* **Base token alias:** `L2_BASE_TOKEN_ADDRESS` maps back to the L1 base token via `toL1Address`.
102+
* **Error handling:** Methods throw typed errors via the adapters’ error handlers. Wrap with `try/catch` or rely on higher-level `try*` patterns.

docs/src/sdk-reference/ethers/withdrawals.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ L2 → L1 withdrawals for ETH and ERC-20 tokens with quote, prepare, create, sta
1010
* **Typical flow:** `quote → create → wait({ for: 'l2' }) → wait({ for: 'ready' }) → finalize`
1111
* **Auto-routing:** ETH vs ERC-20 and base-token vs non-base handled internally
1212
* **Error style:** Throwing methods (`quote`, `prepare`, `create`, `status`, `wait`, `finalize`) + safe variants (`tryQuote`, `tryPrepare`, `tryCreate`, `tryWait`, `tryFinalize`)
13+
* **Token mapping:** Use `sdk.tokens` if you need L1/L2 token addresses or assetIds ahead of time.
1314

1415
## Import
1516

examples/ethers/withdrawals/erc20-nonbase.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
* Example: Withdraw a non-base ERC-20 from L2
44
*
55
* Notes:
6-
* - Pass the L1 token address to `sdk.helpers.l2TokenAddress` to discover its L2 counterpart.
6+
* - Pass the L1 token address to `sdk.tokens.toL2Address` to discover its L2 counterpart.
77
* - Route: `erc20-nonbase` → NTV + L2AssetRouter.withdraw(assetId, assetData).
88
* - SDK will add an L2 approval step (spender = L2NativeTokenVault) if needed.
99
*
1010
* Flow:
1111
* 1) Connect to L1 + L2 RPCs and create Ethers SDK client.
12-
* 2) Resolve the L2 token address using `sdk.helpers.l2TokenAddress(L1_TOKEN)`.
12+
* 2) Resolve the L2 token address using `sdk.tokens.toL2Address(L1_TOKEN)`.
1313
* 3) Inspect balances/symbol/decimals.
1414
* 4) Call `sdk.withdrawals.quote` or `prepare` (optional).
1515
* 5) Call `sdk.withdrawals.create` (approve first if needed, then withdraw).
@@ -42,7 +42,7 @@ async function main() {
4242
const me = (await signer.getAddress()) as Address;
4343

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

4747
const erc20L1 = new Contract(L1_ERC20_TOKEN, IERC20ABI, l1);
4848
const erc20L2 = new Contract(l2Token, IERC20ABI, l2);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, it, expect } from 'bun:test';
2+
import { Interface, AbiCoder, ethers } from 'ethers';
3+
4+
import { createTokensResource } from '../ethers/resources/tokens';
5+
import { createEthersHarness, ADAPTER_TEST_ADDRESSES } from './adapter-harness';
6+
import {
7+
ETH_ADDRESS,
8+
FORMAL_ETH_ADDRESS,
9+
L2_ASSET_ROUTER_ADDRESS,
10+
L2_BASE_TOKEN_ADDRESS,
11+
L2_NATIVE_TOKEN_VAULT_ADDRESS,
12+
} from '../../core/constants';
13+
import { IL2AssetRouterABI, L1NativeTokenVaultABI, L2NativeTokenVaultABI } from '../../core/abi';
14+
import { createNTVCodec } from '../../core/codec/ntv';
15+
16+
const L1NTV = new Interface(L1NativeTokenVaultABI as any);
17+
const L2NTV = new Interface(L2NativeTokenVaultABI as any);
18+
const L2AR = new Interface(IL2AssetRouterABI as any);
19+
20+
const ntvCodec = createNTVCodec({
21+
encode: (types, values) => AbiCoder.defaultAbiCoder().encode(types, values) as `0x${string}`,
22+
keccak256: (data: `0x${string}`) => ethers.keccak256(data) as `0x${string}`,
23+
});
24+
25+
describe('adapters/tokens (ethers)', () => {
26+
it('resolves a non-base ERC20 with L1/L2 mapping and facts', async () => {
27+
const harness = createEthersHarness();
28+
const tokens = createTokensResource(harness.client);
29+
30+
const l1Token = '0x0000000000000000000000000000000000000111' as const;
31+
const l2Token = '0x0000000000000000000000000000000000000222' as const;
32+
const assetId = '0xaaa0000000000000000000000000000000000000000000000000000000000001' as const;
33+
const baseTokenAssetId =
34+
'0xbbb0000000000000000000000000000000000000000000000000000000000002' as const;
35+
36+
// Base chain facts
37+
harness.registry.set(ADAPTER_TEST_ADDRESSES.l1NativeTokenVault, L1NTV, 'assetId', assetId, [
38+
l1Token,
39+
]);
40+
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'assetId', assetId, [l2Token]);
41+
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'tokenAddress', l2Token, [assetId]);
42+
harness.registry.set(
43+
ADAPTER_TEST_ADDRESSES.l1NativeTokenVault,
44+
L1NTV,
45+
'tokenAddress',
46+
l1Token,
47+
[assetId],
48+
);
49+
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'originChainId', 9n, [assetId]);
50+
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'l2TokenAddress', l2Token, [
51+
l1Token,
52+
]);
53+
harness.registry.set(L2_ASSET_ROUTER_ADDRESS, L2AR, 'l1TokenAddress', l1Token, [l2Token]);
54+
harness.registry.set(
55+
L2_NATIVE_TOKEN_VAULT_ADDRESS,
56+
L2NTV,
57+
'BASE_TOKEN_ASSET_ID',
58+
baseTokenAssetId,
59+
);
60+
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'L1_CHAIN_ID', 1n);
61+
harness.registry.set(
62+
ADAPTER_TEST_ADDRESSES.l1NativeTokenVault,
63+
L1NTV,
64+
'WETH_TOKEN',
65+
ADAPTER_TEST_ADDRESSES.baseTokenFor324,
66+
);
67+
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'WETH_TOKEN', L2_BASE_TOKEN_ADDRESS);
68+
69+
const resolved = await tokens.resolve(l1Token);
70+
expect(resolved.kind).toBe('erc20');
71+
expect(resolved.l1.toLowerCase()).toBe(l1Token.toLowerCase());
72+
expect(resolved.l2.toLowerCase()).toBe(l2Token.toLowerCase());
73+
expect(resolved.assetId.toLowerCase()).toBe(assetId.toLowerCase());
74+
expect(resolved.baseTokenAssetId.toLowerCase()).toBe(baseTokenAssetId.toLowerCase());
75+
expect(resolved.originChainId).toBe(9n);
76+
expect(resolved.isChainEthBased).toBe(false);
77+
expect(resolved.wethL1.toLowerCase()).toBe(
78+
ADAPTER_TEST_ADDRESSES.baseTokenFor324.toLowerCase(),
79+
);
80+
expect(resolved.wethL2.toLowerCase()).toBe(L2_BASE_TOKEN_ADDRESS.toLowerCase());
81+
});
82+
83+
it('detects ETH-based chains via baseTokenAssetId', async () => {
84+
const harness = createEthersHarness();
85+
const tokens = createTokensResource(harness.client);
86+
87+
const ethAssetId = ntvCodec.encodeAssetId(1n, L2_NATIVE_TOKEN_VAULT_ADDRESS, ETH_ADDRESS);
88+
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'BASE_TOKEN_ASSET_ID', ethAssetId);
89+
harness.registry.set(L2_NATIVE_TOKEN_VAULT_ADDRESS, L2NTV, 'L1_CHAIN_ID', 1n);
90+
91+
const isEthBased = await tokens.isChainEthBased();
92+
expect(isEthBased).toBe(true);
93+
});
94+
95+
it('normalizes ETH aliases for CREATE2 predictions and base-token alias mapping', async () => {
96+
const harness = createEthersHarness();
97+
const tokens = createTokensResource(harness.client);
98+
99+
const predicted = '0x0000000000000000000000000000000000000c0d' as const;
100+
harness.registry.set(
101+
L2_NATIVE_TOKEN_VAULT_ADDRESS,
102+
L2NTV,
103+
'calculateCreate2TokenAddress',
104+
predicted,
105+
[1n, ETH_ADDRESS],
106+
);
107+
108+
const computed = await tokens.computeL2BridgedAddress({
109+
originChainId: 1n,
110+
l1Token: FORMAL_ETH_ADDRESS,
111+
});
112+
expect(computed.toLowerCase()).toBe(predicted.toLowerCase());
113+
114+
// Base-token alias should map back to L1 base token for the chain
115+
const baseL1 = await tokens.toL1Address(L2_BASE_TOKEN_ADDRESS);
116+
expect(baseL1.toLowerCase()).toBe(ADAPTER_TEST_ADDRESSES.baseTokenFor324.toLowerCase());
117+
});
118+
});

src/adapters/__tests__/utils.test.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,6 @@ const SAMPLE = {
1717
};
1818

1919
describe('adapters/utils — encoding parity', () => {
20-
it('encodeNativeTokenVaultAssetId matches between ethers & viem implementations', () => {
21-
const ethersEncoded = ethersUtils.encodeNativeTokenVaultAssetId(SAMPLE.chainId, SAMPLE.token);
22-
const viemEncoded = viemUtils
23-
.encodeNativeTokenVaultAssetId(SAMPLE.chainId, SAMPLE.token)
24-
.toLowerCase();
25-
26-
expect(ethersEncoded.toLowerCase()).toBe(viemEncoded);
27-
expect(ethersUtils.encodeNTVAssetId(SAMPLE.chainId, SAMPLE.token).toLowerCase()).toBe(
28-
ethersEncoded.toLowerCase(),
29-
);
30-
});
31-
3220
it('encodeNativeTokenVaultTransferData encodes amount/receiver/token identically', () => {
3321
const ethersEncoded = ethersUtils.encodeNativeTokenVaultTransferData(
3422
SAMPLE.amount,
@@ -48,9 +36,6 @@ describe('adapters/utils — encoding parity', () => {
4836
expect(BigInt(amount.toString())).toBe(SAMPLE.amount);
4937
expect((receiver as string).toLowerCase()).toBe(SAMPLE.receiver.toLowerCase());
5038
expect((token as string).toLowerCase()).toBe(SAMPLE.token.toLowerCase());
51-
expect(ethersUtils.encodeNTVTransferData(SAMPLE.amount, SAMPLE.receiver, SAMPLE.token)).toBe(
52-
ethersEncoded,
53-
);
5439
});
5540

5641
it('encodeSecondBridgeDataV1 prefixes 0x01 and encodes payload equally', () => {

src/adapters/ethers/e2e/erc20.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('e2e (ethers): ERC-20 deposit L1->L2 and withdraw L2->L1', () => {
122122
}, 180_000);
123123

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

0 commit comments

Comments
 (0)