Skip to content

Commit dfcede7

Browse files
authored
fix: address issue with first time eth deployments (#73)
1 parent faea1ee commit dfcede7

File tree

5 files changed

+438
-74
lines changed

5 files changed

+438
-74
lines changed
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import { describe, expect, it } from 'bun:test';
2+
3+
import { createDepositsResource } from '../../ethers/resources/deposits/index.ts';
4+
import { routeEthNonBase as routeEthers } from '../../ethers/resources/deposits/routes/eth-nonbase.ts';
5+
import { routeEthNonBase as routeViem } from '../../viem/resources/deposits/routes/eth-nonbase.ts';
6+
import {
7+
ADAPTER_TEST_ADDRESSES,
8+
createEthersHarness,
9+
describeForAdapters,
10+
makeDepositContext,
11+
setBridgehubBaseCost,
12+
setErc20Allowance,
13+
} from '../adapter-harness.ts';
14+
import {
15+
decodeSecondBridgeErc20,
16+
decodeTwoBridgeOuter,
17+
parseApproveTx,
18+
} from '../decode-helpers.ts';
19+
import { ETH_ADDRESS, FORMAL_ETH_ADDRESS, SAFE_L1_BRIDGE_GAS } from '../../../core/constants.ts';
20+
import { isZKsyncError } from '../../../core/types/errors.ts';
21+
import type { TokensResource, ResolvedToken } from '../../../core/types/flows/token.ts';
22+
import type { Address, Hex } from '../../../core/types/primitives.ts';
23+
24+
const ROUTES = {
25+
ethers: routeEthers(),
26+
viem: routeViem(),
27+
} as const;
28+
29+
const MIN_L2_GAS_FOR_ETH_NONBASE = 600_000n;
30+
const SAFE_NONBASE_L2_GAS_LIMIT = 3_000_000n;
31+
const BASE_TOKEN = ADAPTER_TEST_ADDRESSES.baseTokenFor324;
32+
const RECEIVER = '0x4444444444444444444444444444444444444444' as Address;
33+
const BASE_TOKEN_ASSET_ID = `0x${'11'.repeat(32)}` as Hex;
34+
const ETH_ASSET_ID = `0x${'22'.repeat(32)}` as Hex;
35+
const WETH_L1 = '0x5555555555555555555555555555555555555555' as Address;
36+
const WETH_L2 = '0x6666666666666666666666666666666666666666' as Address;
37+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address;
38+
39+
function makeResolvedEthToken(l2Token: Address = ETH_ADDRESS): ResolvedToken {
40+
return {
41+
kind: 'eth',
42+
l1: FORMAL_ETH_ADDRESS,
43+
l2: l2Token,
44+
assetId: ETH_ASSET_ID,
45+
originChainId: 1n,
46+
isChainEthBased: false,
47+
baseTokenAssetId: BASE_TOKEN_ASSET_ID,
48+
wethL1: WETH_L1,
49+
wethL2: WETH_L2,
50+
};
51+
}
52+
53+
function makeEthNonBaseTokens(baseToken: Address, l2Token: Address = ETH_ADDRESS): TokensResource {
54+
const resolved = makeResolvedEthToken(l2Token);
55+
56+
return {
57+
async resolve() {
58+
return resolved;
59+
},
60+
async l1TokenFromAssetId() {
61+
return baseToken;
62+
},
63+
} as TokensResource;
64+
}
65+
66+
describeForAdapters('adapters/deposits/routeEthNonBase', (kind, factory) => {
67+
it('adds a spender-qualified base-token approval when allowance is insufficient', async () => {
68+
const harness = factory();
69+
const ctx = makeDepositContext(harness, {
70+
l2GasLimit: MIN_L2_GAS_FOR_ETH_NONBASE,
71+
baseTokenL1: BASE_TOKEN,
72+
baseIsEth: false,
73+
resolvedToken: makeResolvedEthToken(),
74+
});
75+
const amount = 5_000n;
76+
const baseCost = 4_000n;
77+
const mintValue = baseCost + ctx.operatorTip;
78+
79+
setBridgehubBaseCost(harness, ctx, baseCost, { l2GasLimit: MIN_L2_GAS_FOR_ETH_NONBASE });
80+
setErc20Allowance(harness, BASE_TOKEN, ctx.sender, ctx.l1AssetRouter, mintValue - 1n);
81+
82+
const res = await ROUTES[kind].build(
83+
{ token: FORMAL_ETH_ADDRESS, amount, to: RECEIVER } as any,
84+
ctx as any,
85+
);
86+
87+
expect(res.approvals.length).toBe(1);
88+
expect(res.steps.length).toBe(2);
89+
expect(res.fees?.token.toLowerCase()).toBe(BASE_TOKEN.toLowerCase());
90+
expect(res.fees?.l2.baseCost).toBe(baseCost);
91+
expect(res.fees?.mintValue).toBe(mintValue);
92+
93+
const [approvalNeed] = res.approvals;
94+
expect(approvalNeed.token.toLowerCase()).toBe(BASE_TOKEN.toLowerCase());
95+
expect(approvalNeed.spender.toLowerCase()).toBe(ctx.l1AssetRouter.toLowerCase());
96+
expect(approvalNeed.amount).toBe(mintValue);
97+
98+
const [approve, bridge] = res.steps;
99+
expect(approve.kind).toBe('approve');
100+
expect(approve.key).toBe(`approve:${BASE_TOKEN}:${ctx.l1AssetRouter}`);
101+
102+
const approveInfo = parseApproveTx(kind, approve.tx);
103+
expect(approveInfo.to).toBe(BASE_TOKEN.toLowerCase());
104+
expect(approveInfo.spender).toBe(ctx.l1AssetRouter.toLowerCase());
105+
expect(approveInfo.amount).toBe(mintValue);
106+
107+
expect(bridge.key).toBe('bridgehub:two-bridges:eth-nonbase');
108+
109+
if (kind === 'ethers') {
110+
const tx = bridge.tx as any;
111+
const info = decodeTwoBridgeOuter(tx.data);
112+
const bridgeArgs = decodeSecondBridgeErc20(info.secondBridgeCalldata);
113+
114+
expect((tx.to as string).toLowerCase()).toBe(ADAPTER_TEST_ADDRESSES.bridgehub.toLowerCase());
115+
expect((tx.from as string).toLowerCase()).toBe(ctx.sender.toLowerCase());
116+
expect(BigInt(tx.value ?? 0n)).toBe(amount);
117+
expect(BigInt(info.mintValue)).toBe(mintValue);
118+
expect(BigInt(info.secondBridgeValue)).toBe(amount);
119+
expect(bridgeArgs.token).toBe(ETH_ADDRESS.toLowerCase());
120+
expect(bridgeArgs.amount).toBe(amount);
121+
expect(bridgeArgs.receiver).toBe(RECEIVER.toLowerCase());
122+
} else {
123+
const tx = bridge.tx as any;
124+
const req = (tx.args?.[0] ?? {}) as any;
125+
126+
expect((tx.address as string).toLowerCase()).toBe(
127+
ADAPTER_TEST_ADDRESSES.bridgehub.toLowerCase(),
128+
);
129+
expect((tx.account as string).toLowerCase()).toBe(ctx.sender.toLowerCase());
130+
expect(BigInt(tx.value ?? 0n)).toBe(amount);
131+
expect(BigInt(req.mintValue ?? 0n)).toBe(mintValue);
132+
expect(BigInt(req.secondBridgeValue ?? 0n)).toBe(amount);
133+
}
134+
});
135+
136+
it('uses a safe L2 gas limit when the bridged ETH token is not yet deployed on L2', async () => {
137+
const harness = factory();
138+
const ctx = makeDepositContext(harness, {
139+
l2GasLimit: undefined,
140+
baseTokenL1: BASE_TOKEN,
141+
baseIsEth: false,
142+
resolvedToken: makeResolvedEthToken(ZERO_ADDRESS),
143+
});
144+
const amount = 3_000n;
145+
const baseCost = 9_000n;
146+
const mintValue = baseCost + ctx.operatorTip;
147+
148+
setBridgehubBaseCost(harness, ctx, baseCost, { l2GasLimit: SAFE_NONBASE_L2_GAS_LIMIT });
149+
setErc20Allowance(harness, BASE_TOKEN, ctx.sender, ctx.l1AssetRouter, mintValue);
150+
151+
const res = await ROUTES[kind].build(
152+
{ token: FORMAL_ETH_ADDRESS, amount, to: RECEIVER } as any,
153+
ctx as any,
154+
);
155+
156+
expect(res.approvals.length).toBe(0);
157+
expect(res.fees?.l2.baseCost).toBe(baseCost);
158+
expect(res.fees?.mintValue).toBe(mintValue);
159+
160+
const bridge = res.steps.at(-1)!;
161+
if (kind === 'ethers') {
162+
const info = decodeTwoBridgeOuter((bridge.tx as any).data);
163+
expect(BigInt(info.l2GasLimit)).toBe(SAFE_NONBASE_L2_GAS_LIMIT);
164+
} else {
165+
const req = ((bridge.tx as any).args?.[0] ?? {}) as any;
166+
expect(BigInt(req.l2GasLimit ?? 0n)).toBe(SAFE_NONBASE_L2_GAS_LIMIT);
167+
}
168+
});
169+
170+
it('uses a safe L2 gas limit when estimation fails without an override', async () => {
171+
const harness = factory();
172+
const ctx = makeDepositContext(harness, {
173+
l2GasLimit: undefined,
174+
baseTokenL1: BASE_TOKEN,
175+
baseIsEth: false,
176+
resolvedToken: makeResolvedEthToken(),
177+
});
178+
const amount = 3_500n;
179+
const baseCost = 10_000n;
180+
const mintValue = baseCost + ctx.operatorTip;
181+
182+
setBridgehubBaseCost(harness, ctx, baseCost, { l2GasLimit: SAFE_NONBASE_L2_GAS_LIMIT });
183+
setErc20Allowance(harness, BASE_TOKEN, ctx.sender, ctx.l1AssetRouter, mintValue);
184+
185+
if (kind === 'ethers') {
186+
harness.setL2EstimateGas(new Error('no gas'));
187+
} else {
188+
harness.setEstimateGas(new Error('no gas'), 'l2');
189+
}
190+
191+
const res = await ROUTES[kind].build(
192+
{ token: FORMAL_ETH_ADDRESS, amount, to: RECEIVER } as any,
193+
ctx as any,
194+
);
195+
196+
expect(res.approvals.length).toBe(0);
197+
expect(res.fees?.l2.baseCost).toBe(baseCost);
198+
expect(res.fees?.mintValue).toBe(mintValue);
199+
200+
const bridge = res.steps.at(-1)!;
201+
if (kind === 'ethers') {
202+
const info = decodeTwoBridgeOuter((bridge.tx as any).data);
203+
expect(BigInt(info.l2GasLimit)).toBe(SAFE_NONBASE_L2_GAS_LIMIT);
204+
} else {
205+
const req = ((bridge.tx as any).args?.[0] ?? {}) as any;
206+
expect(BigInt(req.l2GasLimit ?? 0n)).toBe(SAFE_NONBASE_L2_GAS_LIMIT);
207+
}
208+
});
209+
210+
if (kind === 'ethers') {
211+
it('falls back to the safe gas limit when bridge estimation fails', async () => {
212+
const harness = factory();
213+
const ctx = makeDepositContext(harness, {
214+
l2GasLimit: MIN_L2_GAS_FOR_ETH_NONBASE,
215+
baseTokenL1: BASE_TOKEN,
216+
baseIsEth: false,
217+
resolvedToken: makeResolvedEthToken(),
218+
});
219+
const amount = 2_000n;
220+
const baseCost = 3_000n;
221+
const mintValue = baseCost + ctx.operatorTip;
222+
223+
setBridgehubBaseCost(harness, ctx, baseCost, { l2GasLimit: MIN_L2_GAS_FOR_ETH_NONBASE });
224+
setErc20Allowance(harness, BASE_TOKEN, ctx.sender, ctx.l1AssetRouter, mintValue);
225+
harness.setEstimateGas(new Error('no gas'));
226+
227+
const res = await ROUTES.ethers.build(
228+
{ token: FORMAL_ETH_ADDRESS, amount, to: RECEIVER } as any,
229+
ctx as any,
230+
);
231+
const bridgeTx = res.steps.at(-1)!.tx as any;
232+
expect(bridgeTx.gasLimit).toBe(SAFE_L1_BRIDGE_GAS);
233+
});
234+
}
235+
236+
it('wraps base-token allowance failures as ZKsyncError', async () => {
237+
const harness = factory();
238+
const ctx = makeDepositContext(harness, {
239+
l2GasLimit: MIN_L2_GAS_FOR_ETH_NONBASE,
240+
baseTokenL1: BASE_TOKEN,
241+
baseIsEth: false,
242+
resolvedToken: makeResolvedEthToken(),
243+
});
244+
const amount = 1_000n;
245+
const baseCost = 2_000n;
246+
247+
setBridgehubBaseCost(harness, ctx, baseCost, { l2GasLimit: MIN_L2_GAS_FOR_ETH_NONBASE });
248+
249+
let caught: unknown;
250+
try {
251+
await ROUTES[kind].build(
252+
{ token: FORMAL_ETH_ADDRESS, amount, to: RECEIVER } as any,
253+
ctx as any,
254+
);
255+
} catch (err) {
256+
caught = err;
257+
}
258+
259+
expect(isZKsyncError(caught)).toBe(true);
260+
expect(String(caught)).toMatch(/Failed to read base-token allowance/);
261+
});
262+
});
263+
264+
describe('adapters/deposits/resource.create (ethers eth-nonbase)', () => {
265+
it('rechecks allowance using the router encoded in the approve step key', async () => {
266+
const harness = createEthersHarness();
267+
const tokens = makeEthNonBaseTokens(BASE_TOKEN);
268+
const deposits = createDepositsResource(harness.client, tokens);
269+
const ctx = makeDepositContext(harness, { l2GasLimit: MIN_L2_GAS_FOR_ETH_NONBASE });
270+
const amount = 5_000n;
271+
const baseCost = 4_000n;
272+
const mintValue = baseCost + ctx.operatorTip;
273+
const approveKey = `approve:${BASE_TOKEN}:${ctx.l1AssetRouter}`;
274+
const bridgeKey = 'bridgehub:two-bridges:eth-nonbase';
275+
const blockTags: string[] = [];
276+
const sent: Array<{ to?: string; nonce?: number }> = [];
277+
278+
setBridgehubBaseCost(harness, ctx, baseCost, { l2GasLimit: MIN_L2_GAS_FOR_ETH_NONBASE });
279+
setErc20Allowance(harness, BASE_TOKEN, ctx.sender, ctx.l1AssetRouter, mintValue - 1n);
280+
281+
(harness.l1 as any).getBalance = async () => amount + 1n;
282+
(harness.l1 as any).getTransactionCount = async (_from: string, blockTag: string) => {
283+
blockTags.push(blockTag);
284+
return 12;
285+
};
286+
(harness.signer as any).populateTransaction = async (tx: any) => tx;
287+
(harness.signer as any).sendTransaction = async (tx: any) => {
288+
sent.push({ to: tx.to, nonce: tx.nonce });
289+
const hash = `0x${sent.length.toString(16).padStart(64, '0')}`;
290+
return {
291+
hash,
292+
wait: async () => ({ status: 1 }),
293+
};
294+
};
295+
296+
const handle = await deposits.create({
297+
token: FORMAL_ETH_ADDRESS,
298+
amount,
299+
to: RECEIVER,
300+
l2GasLimit: MIN_L2_GAS_FOR_ETH_NONBASE,
301+
operatorTip: ctx.operatorTip,
302+
});
303+
304+
expect(blockTags).toEqual(['latest', 'pending']);
305+
expect(sent.length).toBe(2);
306+
expect((sent[0].to as string).toLowerCase()).toBe(BASE_TOKEN.toLowerCase());
307+
expect(sent[0].nonce).toBe(12);
308+
expect(sent[1].nonce).toBe(13);
309+
expect(handle.stepHashes[approveKey]).toBe(`0x${'1'.padStart(64, '0')}`);
310+
expect(handle.stepHashes[bridgeKey]).toBe(`0x${'2'.padStart(64, '0')}`);
311+
expect(handle.l1TxHash).toBe(handle.stepHashes[bridgeKey]);
312+
});
313+
});

src/adapters/ethers/resources/deposits/routes/eth-nonbase.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { ApprovalNeed, PlanStep } from '../../../../../core/types/flows/bas
99
import { createErrorHandlers } from '../../../errors/error-ops';
1010
import { OP_DEPOSITS } from '../../../../../core/types';
1111
import { isETH } from '../../../../../core/utils/addr';
12-
import { quoteL1Gas, quoteL2Gas } from '../services/gas.ts';
12+
import { determineEthNonBaseL2Gas, quoteL1Gas } from '../services/gas.ts';
1313
import { quoteL2BaseCost } from '../services/fee.ts';
1414
import { SAFE_L1_BRIDGE_GAS } from '../../../../../core/constants.ts';
1515
import { buildFeeBreakdown } from '../../../../../core/resources/deposits/fee.ts';
@@ -79,11 +79,9 @@ export function routeEthNonBase(): DepositRouteStrategy {
7979
data: '0x',
8080
value: 0n,
8181
};
82-
const l2GasParams = await quoteL2Gas({
82+
const l2GasParams = await determineEthNonBaseL2Gas({
8383
ctx,
84-
route: 'eth-nonbase',
85-
l2TxForModeling: l2TxModel,
86-
overrideGasLimit: ctx.l2GasLimit,
84+
modelTx: l2TxModel,
8785
});
8886
if (!l2GasParams) throw new Error('Failed to estimate L2 gas parameters.');
8987

@@ -109,7 +107,7 @@ export function routeEthNonBase(): DepositRouteStrategy {
109107
if (allowance < mintValue) {
110108
approvals.push({ token: baseToken, spender: ctx.l1AssetRouter, amount: mintValue });
111109
steps.push({
112-
key: `approve:${baseToken}`,
110+
key: `approve:${baseToken}:${ctx.l1AssetRouter}`,
113111
kind: 'approve',
114112
description: `Approve base token for fees (mintValue)`,
115113
tx: {

0 commit comments

Comments
 (0)