Skip to content

Commit c1cc1af

Browse files
committed
fix: address review feedback on validation, errors, and native token address
- Correct native token sentinel to the 40-char EIP-55 address (fixes 2 failing tests) - Throw ZeroExValidationError from the constructor and for exact-in/exact-out validation (neither/both amounts) via a shared _assertExactlyOneAmount helper - Guard swidge against illiquid routes after /quote - Align defaultSlippage JSDoc and swidge @throws with actual behavior - Expand test coverage: exact-out/slippage min-amount fallbacks, transaction revert and timeout errors, and the api-client apiKey guard (85% -> 93%) - Ignore generated coverage/ output
1 parent 5bb4344 commit c1cc1af

4 files changed

Lines changed: 112 additions & 11 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
node_modules/
22
types/
3+
coverage/
34
*.log
45
.env
56
.DS_Store

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Call `getSupportedChains()` to retrieve the full list at runtime.
135135

136136
The 0x Swap API accepts **any liquid ERC-20 token by contract address**. There is no supported token list — `getSupportedTokens()` throws `NotImplementedError`. Pass token addresses directly to `quoteSwidge` and `swidge`.
137137

138-
For native ETH (or any chain's native token) as a **buy token**, use `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeeeE` or the alias `'native'`. Selling native ETH is not supported by the AllowanceHolder flow — wrap to WETH first.
138+
For native ETH (or any chain's native token) as a **buy token**, use `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE` or the alias `'native'`. Selling native ETH is not supported by the AllowanceHolder flow — wrap to WETH first.
139139

140140
---
141141

‎src/0x-protocol.js‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,15 @@ import { ZeroExFeeLimitExceededError, ZeroExInsufficientLiquidityError, ZeroExRe
4040
* @property {number | string} chainId - The EVM chain ID of the bound wallet account. Required.
4141
* @property {string} apiKey - The 0x API key. Required.
4242
* @property {string} [baseUrl] - The 0x API base URL. Defaults to 'https://api.0x.org'.
43-
* @property {number} [defaultSlippage] - Default slippage tolerance as a decimal (e.g. 0.005 = 0.5%). Defaults to 0.005.
43+
* @property {number} [defaultSlippage] - Default slippage tolerance as a decimal (e.g. 0.005 = 0.5%). If omitted, no slippage parameter is sent and the 0x API default applies.
4444
* @property {boolean} [skipApproval] - Skip automatic ERC-20 approval before executing a swap.
4545
* @property {number | bigint} [maxNetworkFeeBps] - Maximum acceptable network fee in basis points of the input amount.
4646
* @property {number | bigint} [maxProtocolFeeBps] - Maximum acceptable protocol fee in basis points of the input amount.
4747
*/
4848

4949
// 0x uses this checksummed sentinel for native ETH / chain native token.
5050
// Must use the EIP-55 checksummed form — the 0x API rejects the all-lowercase variant.
51-
const NATIVE_TOKEN_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeeeE'
51+
const NATIVE_TOKEN_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'
5252
const NATIVE_TOKEN_ADDRESS_LOWER = NATIVE_TOKEN_ADDRESS.toLowerCase()
5353

5454
// Identifiers callers may pass to mean "native token"
@@ -120,10 +120,10 @@ export default class ZeroExProtocol extends SwidgeProtocol {
120120
super(account, config)
121121

122122
if (!config.apiKey) {
123-
throw new Error('ZeroExProtocol requires config.apiKey.')
123+
throw new ZeroExValidationError('ZeroExProtocol requires config.apiKey.')
124124
}
125125
if (config.chainId == null) {
126-
throw new Error('ZeroExProtocol requires config.chainId identifying the EVM chain of the bound account.')
126+
throw new ZeroExValidationError('ZeroExProtocol requires config.chainId identifying the EVM chain of the bound account.')
127127
}
128128

129129
/**
@@ -153,9 +153,7 @@ export default class ZeroExProtocol extends SwidgeProtocol {
153153
* @throws {ZeroExInsufficientLiquidityError} If no route is available.
154154
*/
155155
async quoteSwidge (options) {
156-
if (options.fromTokenAmount == null && options.toTokenAmount == null) {
157-
throw new ZeroExValidationError('Either fromTokenAmount (exact-in) or toTokenAmount (exact-out) must be provided.')
158-
}
156+
this._assertExactlyOneAmount(options)
159157

160158
const chainId = Number(this._config.chainId)
161159

@@ -231,7 +229,7 @@ export default class ZeroExProtocol extends SwidgeProtocol {
231229
* @param {SwidgeOptions} options
232230
* @param {SwidgeProtocolConfig} [config] - Per-call overrides for fee caps.
233231
* @returns {Promise<SwidgeResult>}
234-
* @throws {Error} If no full account was provided at construction.
232+
* @throws {ZeroExReadOnlyError} If no full account was provided at construction.
235233
* @throws {ZeroExFeeLimitExceededError} If a fee cap is exceeded.
236234
*/
237235
async swidge (options, config) {
@@ -246,6 +244,8 @@ export default class ZeroExProtocol extends SwidgeProtocol {
246244
`Cross-chain bridging is not supported. toChain (${options.toChain}) must match the configured chainId (${chainId}).`
247245
)
248246
}
247+
this._assertExactlyOneAmount(options)
248+
249249
const sellToken = this._normalizeToken(options.fromToken)
250250
const buyToken = this._normalizeToken(options.toToken)
251251
const taker = await this._account.getAddress()
@@ -269,6 +269,10 @@ export default class ZeroExProtocol extends SwidgeProtocol {
269269

270270
const quote = await this._api.quote(chainId, params)
271271

272+
if (!quote.liquidityAvailable) {
273+
throw new ZeroExInsufficientLiquidityError({ sellToken, buyToken, chainId })
274+
}
275+
272276
this._checkFeeLimits(quote, sellToken, config)
273277

274278
const sellAmount = BigInt(quote.sellAmount)
@@ -405,6 +409,25 @@ export default class ZeroExProtocol extends SwidgeProtocol {
405409
// Protected helpers
406410
// ---------------------------------------------------------------------------
407411

412+
/**
413+
* Asserts that exactly one of fromTokenAmount (exact-in) or toTokenAmount
414+
* (exact-out) is provided.
415+
*
416+
* @protected
417+
* @param {SwidgeOptions} options
418+
* @throws {ZeroExValidationError} If neither or both amounts are provided.
419+
*/
420+
_assertExactlyOneAmount (options) {
421+
const hasIn = options.fromTokenAmount != null
422+
const hasOut = options.toTokenAmount != null
423+
if (!hasIn && !hasOut) {
424+
throw new ZeroExValidationError('Either fromTokenAmount (exact-in) or toTokenAmount (exact-out) must be provided.')
425+
}
426+
if (hasIn && hasOut) {
427+
throw new ZeroExValidationError('Provide either fromTokenAmount (exact-in) or toTokenAmount (exact-out), not both.')
428+
}
429+
}
430+
408431
/**
409432
* Resolves the taker address from the bound account or the options recipient.
410433
*

‎tests/0x-protocol.test.js‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { beforeEach, describe, expect, jest, test } from '@jest/globals'
22

33
import ZeroExProtocol from '../index.js'
4-
import { ZeroExApiError, ZeroExFeeLimitExceededError, ZeroExInsufficientLiquidityError, ZeroExReadOnlyError } from '../src/errors.js'
4+
import ZeroExApiClient from '../src/api-client.js'
5+
import { ZeroExApiError, ZeroExFeeLimitExceededError, ZeroExInsufficientLiquidityError, ZeroExReadOnlyError, ZeroExValidationError, ZeroExTransactionRevertedError, ZeroExTimeoutError } from '../src/errors.js'
56
import { NotImplementedError } from '@tetherto/wdk-wallet'
67

78
const BASE_URL = 'https://api.0x.org'
@@ -178,6 +179,12 @@ describe('ZeroExProtocol', () => {
178179
).rejects.toThrow()
179180
})
180181

182+
test('throws when both fromTokenAmount and toTokenAmount given', async () => {
183+
await expect(
184+
protocol.quoteSwidge({ fromToken: USDC, toToken: WETH, fromTokenAmount: 1n, toTokenAmount: 1n })
185+
).rejects.toThrow(ZeroExValidationError)
186+
})
187+
181188
test('throws when toChain differs from configured chainId', async () => {
182189
await expect(
183190
protocol.quoteSwidge({ fromToken: USDC, toToken: WETH, fromTokenAmount: 1n, toChain: 42161 })
@@ -191,6 +198,34 @@ describe('ZeroExProtocol', () => {
191198
).resolves.toBeDefined()
192199
})
193200

201+
test('derives toTokenAmountMin from slippage when minBuyAmount is absent (exact-in)', async () => {
202+
const { minBuyAmount, ...noMin } = PRICE_RESPONSE
203+
global.fetch = mockFetch({ '/swap/allowance-holder/price': noMin })
204+
205+
const quote = await protocol.quoteSwidge({
206+
fromToken: USDC,
207+
toToken: WETH,
208+
fromTokenAmount: 100000000n,
209+
slippage: 0.01
210+
})
211+
212+
expect(quote.toTokenAmountMin > 0n).toBe(true)
213+
expect(quote.toTokenAmountMin < quote.toTokenAmount).toBe(true)
214+
})
215+
216+
test('sets toTokenAmountMin to the requested amount for exact-out without minBuyAmount', async () => {
217+
const { minBuyAmount, ...noMin } = PRICE_RESPONSE
218+
global.fetch = mockFetch({ '/swap/allowance-holder/price': noMin })
219+
220+
const quote = await protocol.quoteSwidge({
221+
fromToken: USDC,
222+
toToken: WETH,
223+
toTokenAmount: 500000000000000n
224+
})
225+
226+
expect(quote.toTokenAmountMin).toBe(500000000000000n)
227+
})
228+
194229
test('wraps 0x API errors', async () => {
195230
global.fetch = mockFetch({
196231
'/swap/allowance-holder/price': {
@@ -209,7 +244,7 @@ describe('ZeroExProtocol', () => {
209244

210245
const call = global.fetch.mock.calls[0][0]
211246
expect(new URL(call).searchParams.get('sellToken')).toBe(
212-
'0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
247+
'0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'
213248
)
214249
})
215250
})
@@ -286,6 +321,42 @@ describe('ZeroExProtocol', () => {
286321
).rejects.toThrow('Cross-chain bridging is not supported')
287322
})
288323

324+
test('throws when both fromTokenAmount and toTokenAmount given', async () => {
325+
await expect(
326+
protocol.swidge({ fromToken: USDC, toToken: WETH, fromTokenAmount: 1n, toTokenAmount: 1n })
327+
).rejects.toThrow(ZeroExValidationError)
328+
})
329+
330+
test('throws ZeroExInsufficientLiquidityError when quote has no liquidity', async () => {
331+
global.fetch = mockFetch({
332+
'/swap/allowance-holder/quote': { ...QUOTE_RESPONSE, liquidityAvailable: false }
333+
})
334+
335+
await expect(
336+
protocol.swidge({ fromToken: USDC, toToken: WETH, fromTokenAmount: 100000000n })
337+
).rejects.toThrow(ZeroExInsufficientLiquidityError)
338+
339+
expect(account.sendTransaction).not.toHaveBeenCalled()
340+
})
341+
342+
test('throws ZeroExTransactionRevertedError when the approval tx reverts', async () => {
343+
account.getTransactionReceipt.mockResolvedValue({ status: 0 })
344+
345+
await expect(
346+
protocol.swidge({ fromToken: USDC, toToken: WETH, fromTokenAmount: 100000000n })
347+
).rejects.toThrow(ZeroExTransactionRevertedError)
348+
349+
expect(account.sendTransaction).not.toHaveBeenCalled()
350+
})
351+
352+
test('_waitForReceipt throws ZeroExTimeoutError when no receipt appears before the deadline', async () => {
353+
const acc = { getTransactionReceipt: jest.fn(async () => null) }
354+
355+
await expect(
356+
protocol._waitForReceipt(acc, '0xdeadbeef', { intervalMs: 1, timeoutMs: 5 })
357+
).rejects.toThrow(ZeroExTimeoutError)
358+
})
359+
289360
test('throws ZeroExReadOnlyError when no account is bound', async () => {
290361
const p = new ZeroExProtocol(undefined, { chainId: 1, apiKey: 'key' })
291362
await expect(
@@ -493,3 +564,9 @@ describe('ZeroExProtocol', () => {
493564
})
494565
})
495566
})
567+
568+
describe('ZeroExApiClient', () => {
569+
test('throws without an apiKey', () => {
570+
expect(() => new ZeroExApiClient({})).toThrow('apiKey')
571+
})
572+
})

0 commit comments

Comments
 (0)