Skip to content

24 Pitfalls Guide

The LI.FI Earn API has 24 known pitfalls that cause failures, data corruption, or silent bugs. Pitfalls 1-14 originate from LI.FI’s official integration guide. Pitfalls 15-24 were found by probing the live API: several of them only after they had already broken something in production.

EarnForge eliminates every single one at the SDK layer. Downstream surfaces (CLI, React, MCP, Studio) never need to think about them.


Root Cause Earn Data lives at earn.li.fi, Composer at li.quest. Mixing them returns 404.
SDK Mitigation Two typed clients: EarnDataClient (earn.li.fi) and ComposerClient (li.quest) with hard-coded defaults.
Test pitfall-01-wrong-base-url.test.ts

Pitfall 2 – Auth is required on the Earn Data API

Section titled “Pitfall 2 – Auth is required on the Earn Data API”

This pitfall inverted in April 2026. It previously said the Earn Data API takes no auth header. It now hard-401s without one, and LI.FI’s general API docs still say no key is required, which remains true only for li.quest.

Root Cause earn.li.fi returns 401 Missing x-lifi-api-key header on every endpoint. Code written against the old behaviour fails everywhere at once.
SDK Mitigation EarnDataClient requires a key and throws MissingApiKeyError naming the portal, rather than surfacing a bare 401.
Test pitfall-02-auth-required-on-earn-data.test.ts
Root Cause Composer /v1/quote returns 401 without x-lifi-api-key.
SDK Mitigation ComposerClient constructor throws ComposerError if apiKey is falsy. createEarnForge() gates all Composer paths behind requireComposer().
Test pitfall-03-missing-composer-key.test.ts
Root Cause Composer /v1/quote is GET with query params. POST returns 405.
SDK Mitigation ComposerClient.getQuote() hard-codes method: 'GET' and serializes params to query string.
Test pitfall-04-post-instead-of-get.test.ts
Root Cause Passing the underlying token address as toToken fails. The vault’s share token is the vault address.
SDK Mitigation buildDepositQuote() wires toToken = vault.address automatically.
Test pitfall-05-wrong-totoken.test.ts
Root Cause /v1/vaults returns max 100 per page. Without nextCursor handling you see a fraction of the fleet.
SDK Mitigation EarnDataClient.listAllVaults() is an async iterator that follows nextCursor until exhausted.
Test pitfall-06-ignoring-pagination.test.ts
Root Cause apy.total is always a number, but apy1d, apy7d, apy30d can be null on new or low-volume vaults.
SDK Mitigation AnalyticsSchema types them as number | null. getBestApy() implements a fallback chain: apy.total -> apy30d -> apy7d -> apy1d -> 0.
Test pitfall-07-null-apy.test.ts

Pitfall 8 – tvl.usd is a number (it was a string)

Section titled “Pitfall 8 – tvl.usd is a number (it was a string)”

This pitfall also inverted. The OpenAPI spec still documents a string.

Root Cause analytics.tvl.usd was "12345678.90" and is now 12345678.90. Code calling .split() or expecting a string crashes; code written for the spec is wrong today.
SDK Mitigation parseTvl() accepts either and returns { raw, parsed, bigint }, so a flip back in either direction is a non-event.
Test pitfall-08-tvl-string.test.ts
Root Cause USDC has 6 decimals, WETH has 18. Passing "1" raw sends 1 wei instead of 1 token.
SDK Mitigation toSmallestUnit(amount, decimals) and fromSmallestUnit() handle the conversion. buildDepositQuote() reads decimals from vault.underlyingTokens.
Test pitfall-09-decimal-mismatch.test.ts
Root Cause Quotes expire quickly. Using a cached quote for a transaction can revert.
SDK Mitigation LRUCache with configurable TTL (default 60s). Quote results are not cached; only read-only data (vaults, chains, protocols) is cached.
Test pitfall-10-stale-quote.test.ts
Root Cause Submitting a tx with 0 native balance reverts with an opaque EVM error.
SDK Mitigation preflight() checks nativeBalance and returns a NO_GAS issue before any tx is built.
Test pitfall-11-no-gas-token.test.ts
Root Cause Wallet connected to chain A, vault on chain B. Tx sent to wrong RPC.
SDK Mitigation preflight() compares walletChainId vs vault.chainId and returns a CHAIN_MISMATCH issue.
Test pitfall-12-chain-mismatch.test.ts
Root Cause About 30% of vaults have isTransactional: false. Deposit calls fail with an obscure Composer error.
SDK Mitigation buildDepositQuote() and preflight() both check vault.isTransactional and throw/report before hitting the network.
Test pitfall-13-non-transactional.test.ts
Root Cause Earn Data API has undocumented rate limits. Hammering it returns 429.
SDK Mitigation TokenBucketRateLimiter at 100 req/min. acquireAsync() waits instead of throwing when the bucket is empty.
Test pitfall-14-rate-limit.test.ts
Root Cause Some vaults return underlyingTokens: []. Accessing [0].address throws.
SDK Mitigation VaultSchema allows empty arrays. buildDepositQuote() checks length and requires explicit fromToken when empty. preflight() warns.
Test pitfall-15-empty-underlying-tokens.test.ts
Root Cause About 14% of vaults omit the description field entirely. Accessing it without a guard crashes rendering.
SDK Mitigation VaultSchema types description as z.string().optional(). All display code uses optional chaining.
Test pitfall-16-optional-description.test.ts
Root Cause Morpho vaults return reward: 0. Euler and Aave return reward: null. Inconsistent.
SDK Mitigation ApySchema applies .nullable().transform(v => v ?? 0) – every consumer sees a plain number.
Test pitfall-17-apy-reward-null-vs-zero.test.ts
Root Cause Short-lived or freshly deployed vaults return apy1d: null and sometimes apy7d: null. Code that divides by apy1d gets Infinity.
SDK Mitigation AnalyticsSchema types all three as number | null. getBestApy() fallback chain skips nulls. riskScore() handles missing historical data with a moderate default.
Test pitfall-18-apy1d-null.test.ts

Pitfall 19 – Stale protocol slugs return zero results

Section titled “Pitfall 19 – Stale protocol slugs return zero results”
Root Cause Protocol ids became unversioned. protocol=morpho-v1 returns HTTP 200 with an empty list (no error) so a stale slug is indistinguishable from a protocol with no vaults. LI.FI’s own MCP server still advertises the versioned ids.
SDK Mitigation Ids are resolved from /v1/protocols; a live test asserts every id EarnForge ships still exists upstream.
Test pitfall-19-stale-protocol-slugs.test.ts

Pitfall 20 – Unknown query params fail open

Section titled “Pitfall 20 – Unknown query params fail open”
Root Cause Unrecognised params are dropped silently. minTvl instead of minTvlUsd returns the entire unfiltered fleet, so a “$100M+ TVL” filter appears to work and filters nothing.
SDK Mitigation The client sends minTvlUsd, and a test asserts the emitted URL rather than the response.
Test pitfall-20-silently-ignored-query-params.test.ts

Pitfall 21 – verificationStatus is undocumented

Section titled “Pitfall 21 – verificationStatus is undocumented”
Root Cause Present on every vault, in no spec or changelog, flagging ~9% as suspect: mostly zero_apy, occasionally apy_outlier. Nothing surfaces it, so flagged vaults look like any other.
SDK Mitigation A weighted risk dimension. A flagged vault caps at 7.96 and can never read as low risk; suggest() excludes them unless includeFlagged: true.
Test pitfall-21-undocumented-verification-status.test.ts

Pitfall 22 – The docs contradict the API

Section titled “Pitfall 22 – The docs contradict the API”
Root Cause The OpenAPI spec is wrong in six places and documents fields no vault sends. Following it produces wrong values rather than type errors. The APY case overstates every yield 100×.
SDK Mitigation detectDrift() diffs live vs spec vs schema and reports which pair disagrees. CI runs it daily.
Test pitfall-22-docs-contradict-the-api.test.ts
Root Cause Slugs went from chainId-address to protocol:chainId:_:address. Stored slugs stop resolving.
SDK Mitigation parseVaultSlug() accepts both forms, so previously stored references keep working.
Test pitfall-23-slug-format-change.test.ts

Pitfall 24 – underlyingTokens entries can be partial

Section titled “Pitfall 24 – underlyingTokens entries can be partial”
Root Cause Pitfall 15 anticipated an empty underlyingTokens array. What the API actually sends is stranger: a populated array whose entries carry only an address, with no symbol and no decimals. One vault in the fleet did this, and because the schema required both fields, listAll() threw partway through. The Studio’s vault list read zero in production and earnforge list could not complete without a chain filter.
SDK Mitigation symbol and decimals are optional on the token schema. Consumers that need them resolve on-chain or skip the vault, rather than the whole fleet iteration dying on one entry.
Test pitfall-24-partial-underlying-tokens.test.ts

# Pitfall SDK Guard
1 Wrong base URL Two typed clients with hard-coded URLs
2 Auth on Earn Data Zero auth headers on EarnDataClient
3 Missing Composer key Constructor throws; requireComposer() gate
4 POST instead of GET Hard-coded method: 'GET' on ComposerClient
5 Wrong toToken buildDepositQuote() wires toToken = vault.address
6 Ignoring pagination listAllVaults() async iterator follows cursors
7 Null APY values getBestApy() fallback chain
8 TVL is a string parseTvl() returns typed { raw, parsed, bigint }
9 Decimal mismatch toSmallestUnit() / fromSmallestUnit()
10 Stale quote LRU cache excludes quotes; TTL on read data
11 No gas token preflight() checks native balance
12 Chain mismatch preflight() compares wallet chain vs vault chain
13 Non-transactional Both buildDepositQuote() and preflight() check
14 Rate limit TokenBucketRateLimiter at 100 req/min
15 Empty underlyingTokens Schema allows []; explicit fromToken required
16 Optional description z.string().optional() in schema
17 apy.reward null vs 0 .nullable().transform(v => v ?? 0) normalization
18 apy1d null Typed as number | null; fallback chain; moderate default
19 Stale protocol slugs Ids resolved from /v1/protocols; live test asserts they exist
20 Unknown params fail open Client sends minTvlUsd; test asserts the emitted URL
21 Undocumented verificationStatus Weighted risk dimension; suggest() excludes flagged
22 Docs contradict the API detectDrift() diffs live vs spec vs schema, daily in CI
23 Slug format changed parseVaultSlug() accepts both forms
24 Partial underlyingTokens symbol and decimals optional; one vault cannot break the fleet

All 24 tests are individually named and live under packages/sdk/test/pitfalls/:

Terminal window
# Run all SDK tests (including pitfalls)
pnpm turbo test --filter=@earnforge/sdk
# Run only pitfall tests
cd packages/sdk
pnpm vitest run test/pitfalls/

Each test constructs a minimal fixture that triggers the pitfall and asserts the SDK handles it without error.