Skip to content

Commit b5d08e7

Browse files
authored
fix: address issue with L2 native token withdrawals (#54)
* fix: address issue with l2native token withdrawls * chore: minor adjustment * chore: docs
1 parent eba9243 commit b5d08e7

File tree

5 files changed

+111
-45
lines changed

5 files changed

+111
-45
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ Estimate the operation (route, approvals, gas hints). Does **not** send transact
6666
{{#include ../../../snippets/ethers/reference/withdrawals.test.ts:quote}}
6767
```
6868
69+
**Fee estimation notes**
70+
71+
- If `approvalsNeeded` is non-empty, the withdraw gas estimate may be unavailable and `fees.l2` can be zeros. Treat this as **unknown**, not free.
72+
- After the approval transaction is confirmed, call `quote` or `prepare` again to get a withdraw fee estimate.
73+
- `quote` only covers the withdraw transaction. Approval gas is not included in the fee breakdown.
74+
6975
### `tryQuote(p) → Promise<{ ok: true; value: WithdrawQuote } | { ok: false; error }>`
7076
7177
Result-style `quote`.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ Estimate the operation (route, approvals, gas hints). Does **not** send transact
6666
{{#include ../../../snippets/viem/reference/withdrawals.test.ts:quote}}
6767
```
6868
69+
**Fee estimation notes**
70+
71+
- If `approvalsNeeded` is non-empty, the withdraw gas estimate may be unavailable and `fees.l2` can be zeros. Treat this as **unknown**, not free.
72+
- After the approval transaction is confirmed, call `quote` or `prepare` again to get a withdraw fee estimate.
73+
- `quote` only covers the withdraw transaction. Approval gas is not included in the fee breakdown.
74+
6975
### `tryQuote(p) → Promise<{ ok: true; value: WithdrawQuote } | { ok: false; error }>`
7076
7177
Result-style `quote`.

src/adapters/__tests__/withdrawals/erc20-nonbase.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const ROUTES = {
2424
const TOKEN = '0x6666666666666666666666666666666666666666' as const;
2525
const RECEIVER = '0x7777777777777777777777777777777777777777' as const;
2626
const ASSET_ID = ('0x' + 'aa'.repeat(32)) as `0x${string}`;
27+
const RESOLVED_ASSET_ID = ('0x' + 'bb'.repeat(32)) as `0x${string}`;
2728

2829
const L2_NATIVE_VAULT = L2_NATIVE_TOKEN_VAULT_ADDRESS;
2930

@@ -87,6 +88,63 @@ describeForAdapters('adapters/withdrawals/routeErc20NonBase', (kind, factory) =>
8788
}
8889
});
8990

91+
it('uses L2 NTV assetId even when resolved token assetId mismatches', async () => {
92+
const harness = factory();
93+
const ctx = makeWithdrawalContext(harness, {
94+
l2NativeTokenVault: L2_NATIVE_VAULT,
95+
l2AssetRouter: L2_ASSET_ROUTER_ADDRESS,
96+
});
97+
(ctx as any).resolvedToken = { assetId: RESOLVED_ASSET_ID };
98+
99+
const amount = 1_234n;
100+
101+
setErc20Allowance(harness, TOKEN, ctx.sender, ctx.l2NativeTokenVault, amount);
102+
setL2TokenRegistration(harness, ctx.l2NativeTokenVault, TOKEN, ASSET_ID);
103+
104+
if (kind === 'viem') {
105+
harness.queueSimulateResponses(
106+
[
107+
(args) => ({
108+
request: {
109+
address: args.address,
110+
abi: L2NativeTokenVaultABI,
111+
functionName: 'ensureTokenIsRegistered',
112+
args: args.args,
113+
account: args.account,
114+
},
115+
result: ASSET_ID,
116+
}),
117+
(args) => ({
118+
request: {
119+
address: args.address,
120+
abi: IL2AssetRouterABI,
121+
functionName: 'withdraw',
122+
args: args.args,
123+
account: args.account,
124+
},
125+
}),
126+
],
127+
'l2',
128+
);
129+
}
130+
131+
const res = await ROUTES[kind].build({ token: TOKEN, amount, to: RECEIVER } as any, ctx as any);
132+
const step = res.steps.at(-1);
133+
expect(step?.key).toBe('l2-asset-router:withdraw');
134+
135+
if (kind === 'ethers') {
136+
const tx = step?.tx as any;
137+
const decoded = decodeAssetRouterWithdraw(tx.data);
138+
expect(decoded.assetId).toBe(ASSET_ID);
139+
expect(decoded.assetId).not.toBe(RESOLVED_ASSET_ID);
140+
} else {
141+
const tx = step?.tx as any;
142+
const args = tx.args as unknown[];
143+
expect(args?.[0] ?? '').toBe(ASSET_ID);
144+
expect(args?.[0] ?? '').not.toBe(RESOLVED_ASSET_ID);
145+
}
146+
});
147+
90148
it('adds approval when allowance insufficient', async () => {
91149
const harness = factory();
92150
const ctx = makeWithdrawalContext(harness, {

src/adapters/ethers/resources/withdrawals/routes/erc20-nonbase.ts

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -74,26 +74,21 @@ export function routeErc20NonBase(): WithdrawRouteStrategy {
7474
}
7575

7676
// Compute assetId + assetData
77-
const resolved =
78-
ctx.resolvedToken ??
79-
(ctx.tokens ? await ctx.tokens.resolve(p.token, { chain: 'l2' }) : undefined);
80-
const assetId =
81-
resolved?.assetId ??
82-
(await wrapAs(
83-
'CONTRACT',
84-
OP_WITHDRAWALS.erc20.ensureRegistered,
85-
async () => {
86-
const ntv = await ctx.contracts.l2NativeTokenVault();
87-
const ensured = (await ntv
88-
.getFunction('ensureTokenIsRegistered')
89-
.staticCall(p.token)) as `0x${string}`;
90-
return ensured;
91-
},
92-
{
93-
ctx: { where: 'L2NativeTokenVault.ensureTokenIsRegistered', token: p.token },
94-
message: 'Failed to ensure token is registered in L2NativeTokenVault.',
95-
},
96-
));
77+
const assetId = await wrapAs(
78+
'CONTRACT',
79+
OP_WITHDRAWALS.erc20.ensureRegistered,
80+
async () => {
81+
const ntv = await ctx.contracts.l2NativeTokenVault();
82+
const ensured = (await ntv
83+
.getFunction('ensureTokenIsRegistered')
84+
.staticCall(p.token)) as `0x${string}`;
85+
return ensured;
86+
},
87+
{
88+
ctx: { where: 'L2NativeTokenVault.ensureTokenIsRegistered', token: p.token },
89+
message: 'Failed to ensure token is registered in L2NativeTokenVault.',
90+
},
91+
);
9792
const assetData = await wrapAs(
9893
'INTERNAL',
9994
OP_WITHDRAWALS.erc20.encodeAssetData,
@@ -126,7 +121,10 @@ export function routeErc20NonBase(): WithdrawRouteStrategy {
126121
from: ctx.sender,
127122
};
128123

129-
const withdrawGas = await quoteL2Gas({ ctx, tx: withdrawTx });
124+
// Only estimate withdraw gas when allowance is already sufficient.
125+
// Otherwise the estimation can revert (pre-approval) and produce noisy logs.
126+
const withdrawGas =
127+
current >= p.amount ? await quoteL2Gas({ ctx, tx: withdrawTx }) : undefined;
130128
if (withdrawGas) {
131129
withdrawTx.gasLimit = withdrawGas.gasLimit;
132130
withdrawTx.maxFeePerGas = withdrawGas.maxFeePerGas;

src/adapters/viem/resources/withdrawals/routes/erc20-nonbase.ts

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -100,29 +100,24 @@ export function routeErc20NonBase(): WithdrawRouteStrategy {
100100
});
101101
}
102102

103-
const resolved =
104-
ctx.resolvedToken ??
105-
(ctx.tokens ? await ctx.tokens.resolve(p.token, { chain: 'l2' }) : undefined);
106-
const assetId =
107-
resolved?.assetId ??
108-
(
109-
await wrapAs(
110-
'CONTRACT',
111-
OP_WITHDRAWALS.erc20.ensureRegistered,
112-
() =>
113-
ctx.client.l2.simulateContract({
114-
address: ctx.l2NativeTokenVault,
115-
abi: L2NativeTokenVaultABI,
116-
functionName: 'ensureTokenIsRegistered',
117-
args: [p.token] as const,
118-
account: ctx.client.account,
119-
}),
120-
{
121-
ctx: { where: 'L2NativeTokenVault.ensureTokenIsRegistered', token: p.token },
122-
message: 'Failed to ensure token is registered in L2NativeTokenVault.',
123-
},
124-
)
125-
).result;
103+
const assetId = (
104+
await wrapAs(
105+
'CONTRACT',
106+
OP_WITHDRAWALS.erc20.ensureRegistered,
107+
() =>
108+
ctx.client.l2.simulateContract({
109+
address: ctx.l2NativeTokenVault,
110+
abi: L2NativeTokenVaultABI,
111+
functionName: 'ensureTokenIsRegistered',
112+
args: [p.token] as const,
113+
account: ctx.client.account,
114+
}),
115+
{
116+
ctx: { where: 'L2NativeTokenVault.ensureTokenIsRegistered', token: p.token },
117+
message: 'Failed to ensure token is registered in L2NativeTokenVault.',
118+
},
119+
)
120+
).result;
126121
const assetData = encodeAbiParameters(
127122
[
128123
{ type: 'uint256', name: 'amount' },
@@ -145,7 +140,10 @@ export function routeErc20NonBase(): WithdrawRouteStrategy {
145140
from: ctx.sender,
146141
};
147142

148-
const withdrawGas = await quoteL2Gas({ ctx, tx: withdrawTxCandidate });
143+
// Only estimate withdraw gas when allowance is already sufficient.
144+
// Otherwise the estimation can revert (pre-approval) and produce noisy logs.
145+
const withdrawGas =
146+
current >= p.amount ? await quoteL2Gas({ ctx, tx: withdrawTxCandidate }) : undefined;
149147
if (withdrawGas) {
150148
withdrawTxCandidate.gas = withdrawGas.gasLimit;
151149
withdrawTxCandidate.maxFeePerGas = withdrawGas.maxFeePerGas;

0 commit comments

Comments
 (0)