The LI.FI Earn API has 18 documented pitfalls that can cause failures, data corruption, or
silent bugs. Pitfalls 1-14 originate from LI.FI’s official integration guide. Pitfalls 15-18
were discovered via live API probing against the full vault set (April 2026).
EarnForge eliminates every single one at the SDK layer. Downstream surfaces (CLI, React,
MCP, bot) 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
Root Cause Sending an Authorization or x-lifi-api-key header to Earn Data returns 401 or is silently ignored. SDK Mitigation EarnDataClient.fetch() sends zero auth headers.Test pitfall-02-auth-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/earn/vaults returns max 50 per page. Without nextCursor handling you see less than 8% of vaults.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
Root Cause analytics.tvl.usd comes as "12345678.90" (string), not a number. Arithmetic on it silently produces NaN.SDK Mitigation parseTvl() returns { raw: string, parsed: number, bigint: bigint }. All SDK comparisons go through it.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 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.address6 Ignoring pagination listAllVaults() async iterator follows cursors7 Null APY values getBestApy() fallback chain8 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 balance12 Chain mismatch preflight() compares wallet chain vs vault chain13 Non-transactional Both buildDepositQuote() and preflight() check 14 Rate limit TokenBucketRateLimiter at 100 req/min15 Empty underlyingTokens Schema allows []; explicit fromToken required 16 Optional description z.string().optional() in schema17 apy.reward null vs 0 .nullable().transform(v => v ?? 0) normalization18 apy1d null Typed as number | null; fallback chain; moderate default
All 18 tests are individually named and live under packages/sdk/test/pitfalls/:
# Run all SDK tests (including pitfalls)
pnpm turbo test --filter=@earnforge/sdk
pnpm vitest run test/pitfalls/
Each test constructs a minimal fixture that triggers the pitfall and asserts the SDK handles
it without error.