@earnforge/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1350 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let zod = require("zod");
3
+ //#region src/schemas/vault.ts
4
+ /** Protocol info — always has name + url */
5
+ const ProtocolSchema = zod.z.object({
6
+ name: zod.z.string(),
7
+ url: zod.z.string()
8
+ });
9
+ /** Underlying token — symbol, address, decimals */
10
+ const UnderlyingTokenSchema = zod.z.object({
11
+ symbol: zod.z.string(),
12
+ address: zod.z.string(),
13
+ decimals: zod.z.number()
14
+ });
15
+ /** Pack (deposit or redeem method) */
16
+ const PackSchema = zod.z.object({
17
+ name: zod.z.string(),
18
+ stepsType: zod.z.string()
19
+ });
20
+ /**
21
+ * APY — base and total always present.
22
+ * reward: Morpho returns 0, Euler/Aave return null. Normalize to 0.
23
+ * (Pitfall #17)
24
+ */
25
+ const ApySchema = zod.z.object({
26
+ base: zod.z.number(),
27
+ total: zod.z.number(),
28
+ reward: zod.z.number().nullable().transform((v) => v ?? 0)
29
+ });
30
+ /**
31
+ * TVL — usd is always a string from the API.
32
+ * We parse it into { raw, parsed, bigint } at the boundary.
33
+ * (Pitfall #8)
34
+ */
35
+ const TvlSchema = zod.z.object({ usd: zod.z.string() });
36
+ function parseTvl(tvl) {
37
+ const integerPart = tvl.usd.split(".")[0] ?? "0";
38
+ return {
39
+ raw: tvl.usd,
40
+ parsed: Number(tvl.usd),
41
+ bigint: BigInt(integerPart)
42
+ };
43
+ }
44
+ /**
45
+ * Analytics — apy1d, apy7d, apy30d can all be null.
46
+ * Fallback chain: apy.total → apy30d → apy7d → apy1d
47
+ * (Pitfalls #7, #18)
48
+ */
49
+ const AnalyticsSchema = zod.z.object({
50
+ apy: ApySchema,
51
+ tvl: TvlSchema,
52
+ apy1d: zod.z.number().nullable(),
53
+ apy7d: zod.z.number().nullable(),
54
+ apy30d: zod.z.number().nullable(),
55
+ updatedAt: zod.z.string()
56
+ });
57
+ /**
58
+ * Get best available APY with fallback chain.
59
+ * Order: apy.total → apy30d → apy7d → apy1d
60
+ */
61
+ function getBestApy(analytics) {
62
+ if (analytics.apy.total !== 0) return analytics.apy.total;
63
+ return analytics.apy30d ?? analytics.apy7d ?? analytics.apy1d ?? 0;
64
+ }
65
+ /**
66
+ * Vault schema — generated from real fixture (earn.li.fi, Apr 11 2026).
67
+ *
68
+ * Key edge cases handled:
69
+ * - description is optional (~14% of vaults have it) (Pitfall #16)
70
+ * - underlyingTokens can be empty array (Pitfall #15)
71
+ * - apy.reward null → 0 (Pitfall #17)
72
+ * - apy1d/apy7d/apy30d nullable (Pitfall #18)
73
+ * - tvl.usd is a string (Pitfall #8)
74
+ */
75
+ const VaultSchema = zod.z.object({
76
+ address: zod.z.string(),
77
+ chainId: zod.z.number(),
78
+ name: zod.z.string(),
79
+ slug: zod.z.string(),
80
+ network: zod.z.string(),
81
+ description: zod.z.string().optional(),
82
+ protocol: ProtocolSchema,
83
+ provider: zod.z.string(),
84
+ syncedAt: zod.z.string(),
85
+ tags: zod.z.array(zod.z.string()),
86
+ underlyingTokens: zod.z.array(UnderlyingTokenSchema),
87
+ lpTokens: zod.z.array(zod.z.unknown()),
88
+ analytics: AnalyticsSchema,
89
+ isTransactional: zod.z.boolean(),
90
+ isRedeemable: zod.z.boolean(),
91
+ depositPacks: zod.z.array(PackSchema),
92
+ redeemPacks: zod.z.array(PackSchema)
93
+ });
94
+ /** Paginated vault list response */
95
+ const VaultListResponseSchema = zod.z.object({
96
+ data: zod.z.array(VaultSchema),
97
+ nextCursor: zod.z.string().nullable().optional(),
98
+ total: zod.z.number()
99
+ });
100
+ //#endregion
101
+ //#region src/schemas/chain.ts
102
+ /** Chain schema — from GET /v1/earn/chains */
103
+ const ChainSchema = zod.z.object({
104
+ chainId: zod.z.number(),
105
+ name: zod.z.string(),
106
+ networkCaip: zod.z.string()
107
+ });
108
+ const ChainListResponseSchema = zod.z.array(ChainSchema);
109
+ //#endregion
110
+ //#region src/schemas/protocol.ts
111
+ /** Protocol schema — from GET /v1/earn/protocols */
112
+ const ProtocolDetailSchema = zod.z.object({
113
+ name: zod.z.string(),
114
+ url: zod.z.string()
115
+ });
116
+ const ProtocolListResponseSchema = zod.z.array(ProtocolDetailSchema);
117
+ //#endregion
118
+ //#region src/schemas/portfolio.ts
119
+ /** Position asset */
120
+ const PositionAssetSchema = zod.z.object({
121
+ address: zod.z.string(),
122
+ name: zod.z.string(),
123
+ symbol: zod.z.string(),
124
+ decimals: zod.z.number()
125
+ });
126
+ /** Single portfolio position */
127
+ const PositionSchema = zod.z.object({
128
+ chainId: zod.z.number(),
129
+ protocolName: zod.z.string(),
130
+ asset: PositionAssetSchema,
131
+ balanceUsd: zod.z.string(),
132
+ balanceNative: zod.z.string()
133
+ });
134
+ /** Portfolio response */
135
+ const PortfolioResponseSchema = zod.z.object({ positions: zod.z.array(PositionSchema) });
136
+ //#endregion
137
+ //#region src/schemas/quote.ts
138
+ /** Token info in Composer responses */
139
+ const ComposerTokenSchema = zod.z.object({
140
+ address: zod.z.string(),
141
+ chainId: zod.z.number(),
142
+ symbol: zod.z.string(),
143
+ decimals: zod.z.number(),
144
+ name: zod.z.string(),
145
+ coinKey: zod.z.string().optional(),
146
+ logoURI: zod.z.string().optional(),
147
+ priceUSD: zod.z.string().optional(),
148
+ tags: zod.z.array(zod.z.string()).optional(),
149
+ verificationStatus: zod.z.string().optional(),
150
+ verificationStatusBreakdown: zod.z.array(zod.z.unknown()).optional()
151
+ });
152
+ /** Tool details */
153
+ const ToolDetailsSchema = zod.z.object({
154
+ key: zod.z.string(),
155
+ name: zod.z.string(),
156
+ logoURI: zod.z.string().optional()
157
+ });
158
+ /** Fee split */
159
+ const FeeSplitSchema = zod.z.object({
160
+ integratorFee: zod.z.string(),
161
+ lifiFee: zod.z.string()
162
+ });
163
+ /** Fee cost */
164
+ const FeeCostSchema = zod.z.object({
165
+ name: zod.z.string(),
166
+ description: zod.z.string().optional(),
167
+ token: ComposerTokenSchema,
168
+ amount: zod.z.string(),
169
+ amountUSD: zod.z.string(),
170
+ percentage: zod.z.string(),
171
+ included: zod.z.boolean(),
172
+ feeSplit: FeeSplitSchema.optional()
173
+ });
174
+ /** Gas cost */
175
+ const GasCostSchema = zod.z.object({
176
+ type: zod.z.string(),
177
+ price: zod.z.string().optional(),
178
+ estimate: zod.z.string().optional(),
179
+ limit: zod.z.string().optional(),
180
+ amount: zod.z.string(),
181
+ amountUSD: zod.z.string(),
182
+ token: ComposerTokenSchema
183
+ });
184
+ /** Quote action */
185
+ const QuoteActionSchema = zod.z.object({
186
+ fromToken: ComposerTokenSchema,
187
+ fromAmount: zod.z.string(),
188
+ toToken: ComposerTokenSchema,
189
+ fromChainId: zod.z.number(),
190
+ toChainId: zod.z.number(),
191
+ slippage: zod.z.number(),
192
+ fromAddress: zod.z.string(),
193
+ toAddress: zod.z.string()
194
+ });
195
+ /** Quote estimate */
196
+ const QuoteEstimateSchema = zod.z.object({
197
+ tool: zod.z.string(),
198
+ approvalAddress: zod.z.string().optional(),
199
+ toAmountMin: zod.z.string(),
200
+ toAmount: zod.z.string(),
201
+ fromAmount: zod.z.string(),
202
+ feeCosts: zod.z.array(FeeCostSchema).optional(),
203
+ gasCosts: zod.z.array(GasCostSchema).optional(),
204
+ executionDuration: zod.z.number(),
205
+ fromAmountUSD: zod.z.string().optional(),
206
+ toAmountUSD: zod.z.string().optional()
207
+ });
208
+ /** Transaction request — ready to sign */
209
+ const TransactionRequestSchema = zod.z.object({
210
+ to: zod.z.string(),
211
+ data: zod.z.string(),
212
+ value: zod.z.string(),
213
+ chainId: zod.z.number(),
214
+ gasPrice: zod.z.string().optional(),
215
+ gasLimit: zod.z.string().optional(),
216
+ from: zod.z.string().optional()
217
+ });
218
+ /** Included step */
219
+ const IncludedStepSchema = zod.z.object({
220
+ id: zod.z.string(),
221
+ type: zod.z.string(),
222
+ tool: zod.z.string(),
223
+ toolDetails: ToolDetailsSchema.optional(),
224
+ action: zod.z.record(zod.z.unknown()),
225
+ estimate: zod.z.record(zod.z.unknown())
226
+ });
227
+ /** Full Composer quote response */
228
+ const QuoteResponseSchema = zod.z.object({
229
+ type: zod.z.string(),
230
+ id: zod.z.string(),
231
+ tool: zod.z.string(),
232
+ toolDetails: ToolDetailsSchema.optional(),
233
+ action: QuoteActionSchema,
234
+ estimate: QuoteEstimateSchema,
235
+ includedSteps: zod.z.array(IncludedStepSchema).optional(),
236
+ integrator: zod.z.string().optional(),
237
+ transactionRequest: TransactionRequestSchema,
238
+ transactionId: zod.z.string().optional()
239
+ });
240
+ //#endregion
241
+ //#region src/errors.ts
242
+ var EarnForgeError = class extends Error {
243
+ constructor(message, code) {
244
+ super(message);
245
+ this.code = code;
246
+ this.name = "EarnForgeError";
247
+ }
248
+ };
249
+ var EarnApiError = class extends EarnForgeError {
250
+ constructor(message, status, url) {
251
+ super(message, "EARN_API_ERROR");
252
+ this.status = status;
253
+ this.url = url;
254
+ this.name = "EarnApiError";
255
+ }
256
+ };
257
+ var ComposerError = class extends EarnForgeError {
258
+ constructor(message, status) {
259
+ super(message, "COMPOSER_ERROR");
260
+ this.status = status;
261
+ this.name = "ComposerError";
262
+ }
263
+ };
264
+ var PreflightError = class extends EarnForgeError {
265
+ constructor(message, issues) {
266
+ super(message, "PREFLIGHT_ERROR");
267
+ this.issues = issues;
268
+ this.name = "PreflightError";
269
+ }
270
+ };
271
+ var RateLimitError = class extends EarnForgeError {
272
+ constructor(retryAfter) {
273
+ super(`Rate limited. Retry after ${retryAfter}ms`, "RATE_LIMIT");
274
+ this.retryAfter = retryAfter;
275
+ this.name = "RateLimitError";
276
+ }
277
+ };
278
+ //#endregion
279
+ //#region src/rate-limiter.ts
280
+ /**
281
+ * Token-bucket rate limiter with queued async acquisition.
282
+ * Default: 100 requests per minute for Earn Data API (Pitfall #14).
283
+ */
284
+ var TokenBucketRateLimiter = class {
285
+ tokens;
286
+ lastRefill;
287
+ maxTokens;
288
+ refillRate;
289
+ _queue = Promise.resolve();
290
+ constructor(maxRequestsPerMinute = 100) {
291
+ this.maxTokens = maxRequestsPerMinute;
292
+ this.tokens = maxRequestsPerMinute;
293
+ this.refillRate = maxRequestsPerMinute / 6e4;
294
+ this.lastRefill = Date.now();
295
+ }
296
+ refill() {
297
+ const now = Date.now();
298
+ const elapsed = now - this.lastRefill;
299
+ this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
300
+ this.lastRefill = now;
301
+ }
302
+ acquire() {
303
+ this.refill();
304
+ if (this.tokens < 1) throw new RateLimitError(Math.ceil((1 - this.tokens) / this.refillRate));
305
+ this.tokens -= 1;
306
+ }
307
+ /**
308
+ * Queued async acquire — serializes concurrent callers to prevent
309
+ * race conditions where multiple callers pass the token check simultaneously.
310
+ */
311
+ async acquireAsync() {
312
+ this._queue = this._queue.then(() => this._acquireInternal());
313
+ return this._queue;
314
+ }
315
+ async _acquireInternal() {
316
+ this.refill();
317
+ if (this.tokens < 1) {
318
+ const waitMs = Math.ceil((1 - this.tokens) / this.refillRate);
319
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
320
+ this.refill();
321
+ }
322
+ this.tokens -= 1;
323
+ }
324
+ get remaining() {
325
+ this.refill();
326
+ return Math.floor(this.tokens);
327
+ }
328
+ };
329
+ //#endregion
330
+ //#region src/cache.ts
331
+ /**
332
+ * In-memory LRU cache with configurable TTL.
333
+ * Evicts least-recently-used entries when capacity is reached.
334
+ */
335
+ var LRUCache = class {
336
+ map = /* @__PURE__ */ new Map();
337
+ maxSize;
338
+ ttl;
339
+ constructor(options = {}) {
340
+ this.maxSize = options.maxSize ?? 256;
341
+ this.ttl = options.ttl ?? 6e4;
342
+ }
343
+ get(key) {
344
+ const entry = this.map.get(key);
345
+ if (!entry) return void 0;
346
+ if (Date.now() > entry.expiresAt) {
347
+ this.map.delete(key);
348
+ return;
349
+ }
350
+ this.map.delete(key);
351
+ this.map.set(key, entry);
352
+ return entry.value;
353
+ }
354
+ set(key, value) {
355
+ this.map.delete(key);
356
+ if (this.map.size >= this.maxSize) {
357
+ const firstKey = this.map.keys().next().value;
358
+ if (firstKey !== void 0) this.map.delete(firstKey);
359
+ }
360
+ this.map.set(key, {
361
+ value,
362
+ expiresAt: Date.now() + this.ttl
363
+ });
364
+ }
365
+ has(key) {
366
+ return this.get(key) !== void 0;
367
+ }
368
+ clear() {
369
+ this.map.clear();
370
+ }
371
+ get size() {
372
+ return this.map.size;
373
+ }
374
+ };
375
+ //#endregion
376
+ //#region src/retry.ts
377
+ const DEFAULTS = {
378
+ maxRetries: 3,
379
+ baseDelay: 1e3,
380
+ maxDelay: 1e4
381
+ };
382
+ /**
383
+ * Retry with exponential backoff on 429 / 5xx / network errors.
384
+ */
385
+ async function withRetry(fn, options) {
386
+ const opts = {
387
+ ...DEFAULTS,
388
+ ...options
389
+ };
390
+ let lastError;
391
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) try {
392
+ return await fn();
393
+ } catch (error) {
394
+ lastError = error;
395
+ if (attempt === opts.maxRetries) break;
396
+ if (!isRetryable(error)) throw error;
397
+ const delay = Math.min(opts.baseDelay * 2 ** attempt + Math.random() * 200, opts.maxDelay);
398
+ await new Promise((resolve) => setTimeout(resolve, delay));
399
+ }
400
+ throw lastError;
401
+ }
402
+ function isRetryable(error) {
403
+ if (error instanceof EarnApiError) return error.status === 429 || error.status >= 500;
404
+ if (error instanceof ComposerError) return error.status === 429 || error.status >= 500;
405
+ if (error instanceof Error) {
406
+ const msg = error.message.toLowerCase();
407
+ if (msg.includes("rate limit") || msg.includes("429")) return true;
408
+ if (msg.includes("network") || msg.includes("fetch") || msg.includes("econnreset")) return true;
409
+ }
410
+ return false;
411
+ }
412
+ //#endregion
413
+ //#region src/clients/earn-data-client.ts
414
+ /**
415
+ * Earn Data API base URL — earn.li.fi (Pitfall #1).
416
+ * No auth required (Pitfall #2).
417
+ */
418
+ const DEFAULT_BASE_URL$1 = "https://earn.li.fi";
419
+ var EarnDataClient = class {
420
+ baseUrl;
421
+ cache;
422
+ rateLimiter;
423
+ retryOpts;
424
+ constructor(options = {}) {
425
+ this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL$1;
426
+ this.cache = new LRUCache(options.cache);
427
+ this.rateLimiter = new TokenBucketRateLimiter(options.rateLimiter?.maxPerMinute ?? 100);
428
+ this.retryOpts = options.retry ?? {};
429
+ }
430
+ /**
431
+ * Low-level fetch with rate limiting, caching, retry.
432
+ * No auth header — Earn Data API is public (Pitfall #2).
433
+ */
434
+ async fetch(path, cacheKey, parse) {
435
+ const cached = this.cache.get(cacheKey);
436
+ if (cached !== void 0) return cached;
437
+ await this.rateLimiter.acquireAsync();
438
+ const result = await withRetry(async () => {
439
+ const url = `${this.baseUrl}${path}`;
440
+ const res = await globalThis.fetch(url);
441
+ if (!res.ok) {
442
+ const body = await res.text().catch(() => "");
443
+ throw new EarnApiError(`Earn API error: ${res.status} ${res.statusText}. ${body}`.trim(), res.status, url);
444
+ }
445
+ return parse(await res.json());
446
+ }, this.retryOpts);
447
+ this.cache.set(cacheKey, result);
448
+ return result;
449
+ }
450
+ /** Fetch a single page of vaults */
451
+ async listVaults(params = {}) {
452
+ const searchParams = new URLSearchParams();
453
+ if (params.chainId !== void 0) searchParams.set("chainId", String(params.chainId));
454
+ if (params.asset) searchParams.set("asset", params.asset);
455
+ if (params.minTvl !== void 0) searchParams.set("minTvl", String(params.minTvl));
456
+ if (params.sortBy) searchParams.set("sortBy", params.sortBy);
457
+ if (params.cursor) searchParams.set("cursor", params.cursor);
458
+ const qs = searchParams.toString();
459
+ const path = `/v1/earn/vaults${qs ? `?${qs}` : ""}`;
460
+ return this.fetch(path, `vaults:${qs}`, (data) => VaultListResponseSchema.parse(data));
461
+ }
462
+ /**
463
+ * Async iterator for all vaults with auto-pagination via nextCursor.
464
+ * Page size is 50 (API default).
465
+ * (Pitfall #6)
466
+ */
467
+ async *listAllVaults(params = {}) {
468
+ let cursor;
469
+ do {
470
+ const response = await this.listVaults({
471
+ ...params,
472
+ cursor
473
+ });
474
+ for (const vault of response.data) yield vault;
475
+ cursor = response.nextCursor ?? void 0;
476
+ } while (cursor);
477
+ }
478
+ /**
479
+ * Get a single vault by chainId + address.
480
+ * chainId MUST be a number, not chain name (Pitfall — /vaults/Base/0x... returns 400).
481
+ */
482
+ async getVault(chainId, address) {
483
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address)) throw new EarnApiError(`Invalid vault address format: "${address}"`, 400, address);
484
+ const path = `/v1/earn/vaults/${chainId}/${encodeURIComponent(address)}`;
485
+ return this.fetch(path, `vault:${chainId}:${address}`, (data) => VaultSchema.parse(data));
486
+ }
487
+ /** Get a vault by slug (e.g., "8453-0xbeef...") */
488
+ async getVaultBySlug(slug) {
489
+ const dashIdx = slug.indexOf("-");
490
+ if (dashIdx === -1) throw new EarnApiError("Invalid slug format", 400, slug);
491
+ const chainId = Number(slug.slice(0, dashIdx));
492
+ const address = slug.slice(dashIdx + 1);
493
+ if (Number.isNaN(chainId)) throw new EarnApiError("Invalid chainId in slug", 400, slug);
494
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address)) throw new EarnApiError(`Invalid address in slug: "${address}"`, 400, slug);
495
+ return this.getVault(chainId, address);
496
+ }
497
+ /** List supported chains */
498
+ async listChains() {
499
+ return this.fetch("/v1/earn/chains", "chains", (data) => ChainListResponseSchema.parse(data));
500
+ }
501
+ /** List supported protocols */
502
+ async listProtocols() {
503
+ return this.fetch("/v1/earn/protocols", "protocols", (data) => ProtocolListResponseSchema.parse(data));
504
+ }
505
+ /** Get portfolio positions for a wallet */
506
+ async getPortfolio(walletAddress) {
507
+ const path = `/v1/earn/portfolio/${walletAddress}/positions`;
508
+ return this.fetch(path, `portfolio:${walletAddress}`, (data) => PortfolioResponseSchema.parse(data));
509
+ }
510
+ /** Rate limiter remaining tokens */
511
+ get rateLimitRemaining() {
512
+ return this.rateLimiter.remaining;
513
+ }
514
+ /** Clear cache */
515
+ clearCache() {
516
+ this.cache.clear();
517
+ }
518
+ };
519
+ //#endregion
520
+ //#region src/clients/composer-client.ts
521
+ /**
522
+ * Composer base URL — li.quest (Pitfall #1).
523
+ * Requires x-lifi-api-key header (Pitfall #3).
524
+ * Endpoint is GET, not POST (Pitfall #4).
525
+ */
526
+ const DEFAULT_BASE_URL = "https://li.quest";
527
+ var ComposerClient = class {
528
+ baseUrl;
529
+ apiKey;
530
+ retryOpts;
531
+ constructor(options) {
532
+ if (!options.apiKey) throw new ComposerError("Missing Composer API key. Set LIFI_API_KEY environment variable or pass composerApiKey to createEarnForge().", 401);
533
+ this.apiKey = options.apiKey;
534
+ this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
535
+ this.retryOpts = options.retry ?? {};
536
+ }
537
+ /**
538
+ * Get a deposit/swap/bridge quote.
539
+ * Uses GET, not POST (Pitfall #4).
540
+ * Sends x-lifi-api-key header (Pitfall #3).
541
+ */
542
+ async getQuote(params) {
543
+ return withRetry(async () => {
544
+ const searchParams = new URLSearchParams({
545
+ fromChain: String(params.fromChain),
546
+ toChain: String(params.toChain),
547
+ fromToken: params.fromToken,
548
+ toToken: params.toToken,
549
+ fromAddress: params.fromAddress,
550
+ toAddress: params.toAddress,
551
+ fromAmount: params.fromAmount
552
+ });
553
+ if (params.slippage !== void 0) searchParams.set("slippage", String(params.slippage));
554
+ if (params.fromAmountForGas) searchParams.set("fromAmountForGas", params.fromAmountForGas);
555
+ const url = `${this.baseUrl}/v1/quote?${searchParams.toString()}`;
556
+ const res = await globalThis.fetch(url, {
557
+ method: "GET",
558
+ headers: { "x-lifi-api-key": this.apiKey }
559
+ });
560
+ if (!res.ok) {
561
+ const body = await res.text().catch(() => "");
562
+ throw new ComposerError(`Composer error: ${res.status} ${res.statusText}. ${body}`, res.status);
563
+ }
564
+ const json = await res.json();
565
+ return QuoteResponseSchema.parse(json);
566
+ }, this.retryOpts);
567
+ }
568
+ };
569
+ //#endregion
570
+ //#region src/build-deposit-quote.ts
571
+ /**
572
+ * Build a deposit quote with all 18 pitfalls handled:
573
+ *
574
+ * - toToken = vault.address (Pitfall #5)
575
+ * - Uses correct decimals from underlyingTokens (Pitfall #9)
576
+ * - Validates isTransactional (Pitfall #13)
577
+ * - Validates underlyingTokens is non-empty (Pitfall #15)
578
+ * - GET request via ComposerClient (Pitfall #4)
579
+ * - API key in header via ComposerClient (Pitfall #3)
580
+ */
581
+ async function buildDepositQuote(vault, options, composer) {
582
+ if (!/^0x[0-9a-fA-F]{40}$/.test(options.wallet)) throw new EarnForgeError(`Invalid wallet address: "${options.wallet}". Must be a 0x-prefixed 40-hex-char address.`, "INVALID_WALLET");
583
+ if (!vault.isTransactional) throw new EarnForgeError(`Vault ${vault.slug} is not transactional — deposits are not supported.`, "NOT_TRANSACTIONAL");
584
+ if (vault.underlyingTokens.length === 0 && !options.fromToken) throw new EarnForgeError(`Vault ${vault.slug} has no underlyingTokens. You must specify fromToken explicitly.`, "NO_UNDERLYING_TOKENS");
585
+ const fromTokenAddr = options.fromToken ?? vault.underlyingTokens[0]?.address;
586
+ if (!fromTokenAddr) throw new EarnForgeError("Cannot determine fromToken — vault has no underlyingTokens and none was provided.", "NO_FROM_TOKEN");
587
+ const fromChain = options.fromChain ?? vault.chainId;
588
+ if (fromChain !== vault.chainId && !options.fromToken) throw new EarnForgeError(`Cross-chain deposits require an explicit fromToken. The vault's underlyingTokens are on chain ${vault.chainId}, not chain ${fromChain}.`, "CROSS_CHAIN_FROM_TOKEN_REQUIRED");
589
+ const decimals = vault.underlyingTokens.find((t) => t.address.toLowerCase() === fromTokenAddr.toLowerCase())?.decimals ?? 18;
590
+ const rawAmount = toSmallestUnit(options.fromAmount, decimals);
591
+ const quoteParams = {
592
+ fromChain,
593
+ toChain: vault.chainId,
594
+ fromToken: fromTokenAddr,
595
+ toToken: vault.address,
596
+ fromAddress: options.wallet,
597
+ toAddress: options.wallet,
598
+ fromAmount: rawAmount,
599
+ slippage: options.slippage,
600
+ fromAmountForGas: options.fromAmountForGas
601
+ };
602
+ return {
603
+ quote: await composer.getQuote(quoteParams),
604
+ vault,
605
+ humanAmount: options.fromAmount,
606
+ rawAmount,
607
+ decimals
608
+ };
609
+ }
610
+ /**
611
+ * Convert a human-readable amount to the smallest unit.
612
+ * e.g., "1" with 6 decimals → "1000000" (Pitfall #9)
613
+ */
614
+ function toSmallestUnit(amount, decimals) {
615
+ if (!amount || !/^\d+(\.\d+)?$/.test(amount)) throw new EarnForgeError(`Invalid amount "${amount}". Must be a positive numeric string (e.g., "100" or "1.5").`, "INVALID_AMOUNT");
616
+ const parts = amount.split(".");
617
+ const whole = parts[0] ?? "0";
618
+ let fractional = parts[1] ?? "";
619
+ if (fractional.length > decimals) fractional = fractional.slice(0, decimals);
620
+ else fractional = fractional.padEnd(decimals, "0");
621
+ return `${whole}${fractional}`.replace(/^0+/, "") || "0";
622
+ }
623
+ /**
624
+ * Convert smallest unit to human-readable amount.
625
+ */
626
+ function fromSmallestUnit(rawAmount, decimals) {
627
+ if (decimals === 0) return rawAmount;
628
+ const padded = rawAmount.padStart(decimals + 1, "0");
629
+ const whole = padded.slice(0, padded.length - decimals);
630
+ const fractional = padded.slice(padded.length - decimals).replace(/0+$/, "");
631
+ return fractional ? `${whole}.${fractional}` : whole;
632
+ }
633
+ //#endregion
634
+ //#region src/build-redeem-quote.ts
635
+ /**
636
+ * Build a withdrawal/redeem quote.
637
+ *
638
+ * Withdrawal is the reverse of deposit:
639
+ * - fromToken = vault.address (the vault share token)
640
+ * - toToken = underlying token address (what you get back)
641
+ *
642
+ * Uses the same Composer /v1/quote endpoint — just swapped tokens.
643
+ */
644
+ async function buildRedeemQuote(vault, options, composer) {
645
+ if (!/^0x[0-9a-fA-F]{40}$/.test(options.wallet)) throw new EarnForgeError(`Invalid wallet address: "${options.wallet}".`, "INVALID_WALLET");
646
+ if (!vault.isRedeemable) throw new EarnForgeError(`Vault ${vault.slug} is not redeemable — withdrawals are not supported.`, "NOT_REDEEMABLE");
647
+ const rawAmount = toSmallestUnit(options.fromAmount, 18);
648
+ const toToken = options.toToken ?? vault.underlyingTokens[0]?.address;
649
+ if (!toToken) throw new EarnForgeError("Cannot determine toToken for redeem — vault has no underlyingTokens and none was provided.", "NO_TO_TOKEN");
650
+ const toChain = options.toChain ?? vault.chainId;
651
+ if (toChain !== vault.chainId && !options.toToken) throw new EarnForgeError(`Cross-chain redeems require an explicit toToken address on the destination chain.`, "CROSS_CHAIN_TO_TOKEN_REQUIRED");
652
+ const quoteParams = {
653
+ fromChain: vault.chainId,
654
+ toChain,
655
+ fromToken: vault.address,
656
+ toToken,
657
+ fromAddress: options.wallet,
658
+ toAddress: options.wallet,
659
+ fromAmount: rawAmount,
660
+ slippage: options.slippage
661
+ };
662
+ return {
663
+ quote: await composer.getQuote(quoteParams),
664
+ vault,
665
+ humanAmount: options.fromAmount,
666
+ rawAmount
667
+ };
668
+ }
669
+ //#endregion
670
+ //#region src/preflight.ts
671
+ /**
672
+ * Run preflight checks before a deposit:
673
+ * - isTransactional check (Pitfall #13)
674
+ * - Chain mismatch check (Pitfall #12) — warning for cross-chain, error for same-chain
675
+ * - Gas token balance check (Pitfall #11)
676
+ * - Token balance check (uses string-based toSmallestUnit to avoid float precision loss)
677
+ * - underlyingTokens existence (Pitfall #15)
678
+ * - isRedeemable warning
679
+ */
680
+ function preflight(vault, wallet, options = {}) {
681
+ const issues = [];
682
+ if (!vault.isTransactional) issues.push({
683
+ code: "NOT_TRANSACTIONAL",
684
+ message: `Vault ${vault.slug} is not transactional — cannot deposit.`,
685
+ severity: "error"
686
+ });
687
+ if (options.walletChainId !== void 0 && options.walletChainId !== vault.chainId) issues.push({
688
+ code: "CHAIN_MISMATCH",
689
+ message: `Wallet is on chain ${options.walletChainId} but vault is on chain ${vault.chainId}. ${options.crossChain ? "Composer will handle cross-chain bridging." : "Switch network or use cross-chain deposit."}`,
690
+ severity: "warning"
691
+ });
692
+ if (options.nativeBalance !== void 0 && options.nativeBalance === 0n) issues.push({
693
+ code: "NO_GAS",
694
+ message: "Wallet has 0 native gas token. Transaction will fail.",
695
+ severity: "error"
696
+ });
697
+ if (vault.underlyingTokens.length === 0) issues.push({
698
+ code: "NO_UNDERLYING_TOKENS",
699
+ message: "Vault has no underlyingTokens metadata. You must specify fromToken manually.",
700
+ severity: "warning"
701
+ });
702
+ if (options.tokenBalance !== void 0 && options.depositAmount !== void 0) {
703
+ const decimals = options.tokenDecimals ?? vault.underlyingTokens[0]?.decimals ?? 18;
704
+ const requiredRaw = BigInt(toSmallestUnit(options.depositAmount, decimals));
705
+ if (options.tokenBalance < requiredRaw) issues.push({
706
+ code: "INSUFFICIENT_BALANCE",
707
+ message: `Insufficient token balance. Have: ${options.tokenBalance}, need: ${requiredRaw}`,
708
+ severity: "error"
709
+ });
710
+ }
711
+ if (!vault.isRedeemable) issues.push({
712
+ code: "NOT_REDEEMABLE",
713
+ message: "Vault is not redeemable — you may not be able to withdraw.",
714
+ severity: "warning"
715
+ });
716
+ return {
717
+ ok: issues.filter((i) => i.severity === "error").length === 0,
718
+ issues,
719
+ vault,
720
+ wallet
721
+ };
722
+ }
723
+ //#endregion
724
+ //#region src/risk-scorer.ts
725
+ /** Blue-chip / mature protocol tiers */
726
+ const PROTOCOL_TIERS = {
727
+ "aave-v3": 9,
728
+ "morpho-v1": 9,
729
+ "euler-v2": 7,
730
+ "pendle": 7,
731
+ "maple": 6,
732
+ "ethena-usde": 7,
733
+ "ether.fi-liquid": 7,
734
+ "ether.fi-stake": 7,
735
+ "upshift": 5,
736
+ "neverland": 4,
737
+ "yo-protocol": 4
738
+ };
739
+ /**
740
+ * Compute a composite 0–10 risk score for a vault.
741
+ *
742
+ * Dimensions:
743
+ * - TVL magnitude: higher TVL = lower risk
744
+ * - APY stability: small divergence between apy1d/apy30d/total = more stable
745
+ * - Protocol maturity: known blue-chip protocols score higher
746
+ * - Redeemability: non-redeemable = liquidity risk
747
+ * - Asset type: stablecoin tag = lower asset risk
748
+ */
749
+ function riskScore(vault) {
750
+ const tvlScore = scoreTvl(vault);
751
+ const apyScore = scoreApyStability(vault);
752
+ const protocolScore = scoreProtocol(vault);
753
+ const redeemScore = vault.isRedeemable ? 10 : 3;
754
+ const assetScore = vault.tags.includes("stablecoin") ? 9 : 5;
755
+ const weights = {
756
+ tvl: .25,
757
+ apy: .2,
758
+ protocol: .25,
759
+ redeem: .15,
760
+ asset: .15
761
+ };
762
+ const score = tvlScore * weights.tvl + apyScore * weights.apy + protocolScore * weights.protocol + redeemScore * weights.redeem + assetScore * weights.asset;
763
+ const rounded = Math.round(score * 10) / 10;
764
+ return {
765
+ score: rounded,
766
+ breakdown: {
767
+ tvl: tvlScore,
768
+ apyStability: apyScore,
769
+ protocol: protocolScore,
770
+ redeemability: redeemScore,
771
+ assetType: assetScore
772
+ },
773
+ label: rounded >= 7 ? "low" : rounded >= 4 ? "medium" : "high"
774
+ };
775
+ }
776
+ function scoreTvl(vault) {
777
+ const tvl = parseTvl(vault.analytics.tvl);
778
+ if (tvl.parsed >= 1e8) return 10;
779
+ if (tvl.parsed >= 5e7) return 9;
780
+ if (tvl.parsed >= 1e7) return 8;
781
+ if (tvl.parsed >= 5e6) return 7;
782
+ if (tvl.parsed >= 1e6) return 5;
783
+ if (tvl.parsed >= 1e5) return 3;
784
+ return 1;
785
+ }
786
+ function scoreApyStability(vault) {
787
+ const { apy, apy1d, apy30d } = vault.analytics;
788
+ const total = apy.total;
789
+ if (apy30d === null && apy1d === null) return 5;
790
+ const ref = apy30d ?? apy1d ?? total;
791
+ if (ref === 0 || total === 0) return 5;
792
+ const divergence = Math.abs(total - ref) / Math.max(total, ref);
793
+ if (divergence < .05) return 10;
794
+ if (divergence < .1) return 8;
795
+ if (divergence < .2) return 6;
796
+ if (divergence < .5) return 4;
797
+ return 2;
798
+ }
799
+ function scoreProtocol(vault) {
800
+ return PROTOCOL_TIERS[vault.protocol.name] ?? 3;
801
+ }
802
+ const STRATEGIES = {
803
+ conservative: {
804
+ name: "conservative",
805
+ description: "Stablecoin-tagged, TVL > $50M, APY 3-7%, blue-chip protocols only",
806
+ filters: {
807
+ tags: ["stablecoin"],
808
+ minTvlUsd: 5e7,
809
+ protocols: [
810
+ "aave-v3",
811
+ "morpho-v1",
812
+ "euler-v2",
813
+ "pendle",
814
+ "maple"
815
+ ]
816
+ },
817
+ sort: "apy",
818
+ sortDirection: "desc"
819
+ },
820
+ "max-apy": {
821
+ name: "max-apy",
822
+ description: "Sort by APY descending, no TVL floor",
823
+ filters: {},
824
+ sort: "apy",
825
+ sortDirection: "desc"
826
+ },
827
+ diversified: {
828
+ name: "diversified",
829
+ description: "Spread across 3+ chains, 3+ protocols, mix of stablecoin + LST",
830
+ filters: { minTvlUsd: 1e6 },
831
+ sort: "apy",
832
+ sortDirection: "desc"
833
+ },
834
+ "risk-adjusted": {
835
+ name: "risk-adjusted",
836
+ description: "Filter by risk score >= 7, then sort by APY",
837
+ filters: { minRiskScore: 7 },
838
+ sort: "apy",
839
+ sortDirection: "desc"
840
+ }
841
+ };
842
+ function getStrategy(preset) {
843
+ return STRATEGIES[preset];
844
+ }
845
+ //#endregion
846
+ //#region src/suggest.ts
847
+ /**
848
+ * Portfolio allocation engine.
849
+ * Uses a risk-adjusted score: score = apy / (11 - riskScore)
850
+ * to weight allocations proportionally.
851
+ * Enforces maxChains diversification constraint.
852
+ */
853
+ function suggest(vaults, params) {
854
+ const maxVaults = params.maxVaults ?? 5;
855
+ const maxChains = params.maxChains ?? 5;
856
+ let candidates = params.asset ? vaults.filter((v) => v.underlyingTokens.some((t) => t.symbol.toUpperCase() === params.asset?.toUpperCase())) : [...vaults];
857
+ candidates = candidates.filter((v) => v.isTransactional);
858
+ if (params.strategy) {
859
+ const strategy = STRATEGIES[params.strategy];
860
+ if (strategy) {
861
+ if (strategy.filters.minTvlUsd) {
862
+ const minTvl = strategy.filters.minTvlUsd;
863
+ candidates = candidates.filter((v) => parseTvl(v.analytics.tvl).parsed >= minTvl);
864
+ }
865
+ if (strategy.filters.tags?.length) {
866
+ const requiredTags = strategy.filters.tags;
867
+ candidates = candidates.filter((v) => requiredTags.some((t) => v.tags.includes(t)));
868
+ }
869
+ if (strategy.filters.protocols?.length) {
870
+ const allowedProtocols = strategy.filters.protocols;
871
+ candidates = candidates.filter((v) => allowedProtocols.includes(v.protocol.name));
872
+ }
873
+ if (strategy.filters.minRiskScore) {
874
+ const minScore = strategy.filters.minRiskScore;
875
+ candidates = candidates.filter((v) => riskScore(v).score >= minScore);
876
+ }
877
+ }
878
+ }
879
+ const scored = candidates.map((vault) => {
880
+ const risk = riskScore(vault);
881
+ const apy = vault.analytics.apy.total;
882
+ return {
883
+ vault,
884
+ risk,
885
+ apy,
886
+ allocationScore: apy * (risk.score / 10)
887
+ };
888
+ });
889
+ scored.sort((a, b) => b.allocationScore - a.allocationScore);
890
+ const selectedChains = /* @__PURE__ */ new Set();
891
+ const selected = [];
892
+ for (const item of scored) {
893
+ if (selected.length >= maxVaults) break;
894
+ if (selectedChains.size >= maxChains && !selectedChains.has(item.vault.chainId)) continue;
895
+ selectedChains.add(item.vault.chainId);
896
+ selected.push(item);
897
+ }
898
+ if (selected.length === 0) return {
899
+ totalAmount: params.amount,
900
+ expectedApy: 0,
901
+ allocations: []
902
+ };
903
+ const totalScore = selected.reduce((sum, s) => sum + s.allocationScore, 0);
904
+ const allocations = selected.map((s) => {
905
+ const percentage = totalScore > 0 ? s.allocationScore / totalScore * 100 : 100 / selected.length;
906
+ return {
907
+ vault: s.vault,
908
+ risk: s.risk,
909
+ percentage: Math.round(percentage * 10) / 10,
910
+ amount: Math.round(percentage / 100 * params.amount * 100) / 100,
911
+ apy: s.apy
912
+ };
913
+ });
914
+ const expectedApy = allocations.reduce((sum, a) => sum + a.apy * (a.percentage / 100), 0);
915
+ return {
916
+ totalAmount: params.amount,
917
+ expectedApy: Math.round(expectedApy * 100) / 100,
918
+ allocations
919
+ };
920
+ }
921
+ //#endregion
922
+ //#region src/gas-optimizer.ts
923
+ const CHAIN_NAMES = {
924
+ 1: "Ethereum",
925
+ 10: "Optimism",
926
+ 56: "BSC",
927
+ 100: "Gnosis",
928
+ 130: "Unichain",
929
+ 137: "Polygon",
930
+ 143: "Monad",
931
+ 146: "Sonic",
932
+ 5e3: "Mantle",
933
+ 8453: "Base",
934
+ 42161: "Arbitrum",
935
+ 42220: "Celo",
936
+ 43114: "Avalanche",
937
+ 59144: "Linea",
938
+ 80094: "Berachain",
939
+ 747474: "Katana"
940
+ };
941
+ /**
942
+ * Compare deposit routes from multiple source chains.
943
+ * Returns routes sorted by total cost (cheapest first).
944
+ * Integrates LI.Fuel via fromAmountForGas parameter.
945
+ */
946
+ async function optimizeGasRoutes(vault, composer, options) {
947
+ const fromChains = options.fromChains ?? [vault.chainId];
948
+ const decimals = vault.underlyingTokens[0]?.decimals ?? 18;
949
+ const rawAmount = toSmallestUnit(options.fromAmount, decimals);
950
+ const defaultFromToken = options.fromToken ?? vault.underlyingTokens[0]?.address;
951
+ if (!defaultFromToken && !options.fromTokens) return [];
952
+ const routePromises = fromChains.map(async (fromChain) => {
953
+ try {
954
+ const fromToken = options.fromTokens?.[fromChain] ?? (fromChain === vault.chainId ? defaultFromToken : void 0);
955
+ if (!fromToken) return null;
956
+ const quote = await composer.getQuote({
957
+ fromChain,
958
+ toChain: vault.chainId,
959
+ fromToken,
960
+ toToken: vault.address,
961
+ fromAddress: options.wallet,
962
+ toAddress: options.wallet,
963
+ fromAmount: rawAmount,
964
+ fromAmountForGas: options.fromAmountForGas
965
+ });
966
+ const gasCostUsd = (quote.estimate.gasCosts ?? []).reduce((sum, g) => sum + Number(g.amountUSD), 0);
967
+ const feeCostUsd = (quote.estimate.feeCosts ?? []).reduce((sum, f) => sum + Number(f.amountUSD), 0);
968
+ return {
969
+ fromChain,
970
+ fromChainName: CHAIN_NAMES[fromChain] ?? `Chain ${fromChain}`,
971
+ quote,
972
+ totalCostUsd: gasCostUsd + feeCostUsd,
973
+ gasCostUsd,
974
+ feeCostUsd,
975
+ executionDuration: quote.estimate.executionDuration
976
+ };
977
+ } catch {
978
+ return null;
979
+ }
980
+ });
981
+ return (await Promise.all(routePromises)).filter((r) => r !== null).sort((a, b) => a.totalCostUsd - b.totalCostUsd);
982
+ }
983
+ //#endregion
984
+ //#region src/watch.ts
985
+ /**
986
+ * Watch a vault for APY/TVL changes.
987
+ * Returns an AsyncGenerator that yields events.
988
+ * Supports cancellation via AbortSignal and maxIterations.
989
+ */
990
+ async function* watch(client, vaultSlug, options = {}) {
991
+ const interval = options.interval ?? 6e4;
992
+ const apyThreshold = options.apyDropPercent ?? 20;
993
+ const tvlThreshold = options.tvlDropPercent ?? 30;
994
+ const maxIter = options.maxIterations ?? 0;
995
+ let previous = null;
996
+ let iteration = 0;
997
+ while (true) {
998
+ if (options.signal?.aborted) return;
999
+ if (maxIter > 0 && iteration >= maxIter) return;
1000
+ iteration++;
1001
+ const vault = await client.getVaultBySlug(vaultSlug);
1002
+ const currentApy = vault.analytics.apy.total;
1003
+ const currentTvl = parseTvl(vault.analytics.tvl).parsed;
1004
+ const current = {
1005
+ apy: currentApy,
1006
+ tvlUsd: currentTvl
1007
+ };
1008
+ if (previous) {
1009
+ if (previous.apy > 0) {
1010
+ if ((previous.apy - currentApy) / previous.apy * 100 >= apyThreshold) yield {
1011
+ type: "apy-drop",
1012
+ vault,
1013
+ previous,
1014
+ current,
1015
+ timestamp: /* @__PURE__ */ new Date()
1016
+ };
1017
+ }
1018
+ if (previous.tvlUsd > 0) {
1019
+ if ((previous.tvlUsd - currentTvl) / previous.tvlUsd * 100 >= tvlThreshold) yield {
1020
+ type: "tvl-drop",
1021
+ vault,
1022
+ previous,
1023
+ current,
1024
+ timestamp: /* @__PURE__ */ new Date()
1025
+ };
1026
+ }
1027
+ yield {
1028
+ type: "update",
1029
+ vault,
1030
+ previous,
1031
+ current,
1032
+ timestamp: /* @__PURE__ */ new Date()
1033
+ };
1034
+ } else yield {
1035
+ type: "update",
1036
+ vault,
1037
+ previous: null,
1038
+ current,
1039
+ timestamp: /* @__PURE__ */ new Date()
1040
+ };
1041
+ previous = current;
1042
+ if (options.signal?.aborted) return;
1043
+ await new Promise((resolve) => {
1044
+ const timer = setTimeout(resolve, interval);
1045
+ options.signal?.addEventListener("abort", () => {
1046
+ clearTimeout(timer);
1047
+ resolve();
1048
+ }, { once: true });
1049
+ });
1050
+ }
1051
+ }
1052
+ //#endregion
1053
+ //#region src/apy-history.ts
1054
+ /**
1055
+ * DeFiLlama protocol name mapping.
1056
+ * LI.FI uses names like "morpho-v1", DeFiLlama uses the same.
1057
+ * Some need explicit mapping.
1058
+ */
1059
+ const LIFI_TO_LLAMA_PROJECT = {
1060
+ "aave-v3": "aave-v3",
1061
+ "morpho-v1": "morpho-v1",
1062
+ "euler-v2": "euler-v2",
1063
+ "pendle": "pendle",
1064
+ "maple": "maple-finance",
1065
+ "ethena-usde": "ethena",
1066
+ "ether.fi-liquid": "ether.fi",
1067
+ "ether.fi-stake": "ether.fi-stake",
1068
+ "upshift": "upshift",
1069
+ "neverland": "neverland",
1070
+ "yo-protocol": "yo-protocol"
1071
+ };
1072
+ const CHAIN_ID_TO_LLAMA = {
1073
+ 1: "Ethereum",
1074
+ 10: "Optimism",
1075
+ 56: "BSC",
1076
+ 100: "Gnosis",
1077
+ 130: "Unichain",
1078
+ 137: "Polygon",
1079
+ 143: "Monad",
1080
+ 146: "Sonic",
1081
+ 5e3: "Mantle",
1082
+ 8453: "Base",
1083
+ 42161: "Arbitrum",
1084
+ 42220: "Celo",
1085
+ 43114: "Avalanche",
1086
+ 59144: "Linea",
1087
+ 80094: "Berachain",
1088
+ 747474: "Katana"
1089
+ };
1090
+ const poolsCache = new LRUCache({
1091
+ ttl: 36e5,
1092
+ maxSize: 1
1093
+ });
1094
+ /**
1095
+ * Fetch the DeFiLlama pools list with caching (1 hour TTL).
1096
+ * The pools endpoint returns ~16K pools, ~10MB. We cache it to avoid
1097
+ * fetching it on every call.
1098
+ */
1099
+ async function fetchPools() {
1100
+ const cached = poolsCache.get("all");
1101
+ if (cached) return cached;
1102
+ const res = await globalThis.fetch("https://yields.llama.fi/pools");
1103
+ if (!res.ok) return [];
1104
+ const pools = (await res.json()).data ?? [];
1105
+ poolsCache.set("all", pools);
1106
+ return pools;
1107
+ }
1108
+ /**
1109
+ * Match a LI.FI vault to a DeFiLlama pool.
1110
+ *
1111
+ * DeFiLlama pools have UUID IDs (not addresses). Matching strategy:
1112
+ * 1. Filter by project name (LI.FI "morpho-v1" → DeFiLlama "morpho-v1")
1113
+ * 2. Filter by chain name
1114
+ * 3. Filter by underlying token address (the deposit token)
1115
+ * 4. Match by symbol (vault name)
1116
+ * 5. If multiple matches, pick the one with closest TVL to the LI.FI vault
1117
+ */
1118
+ function matchPool(vault, pools) {
1119
+ const chainName = CHAIN_ID_TO_LLAMA[vault.chainId];
1120
+ if (!chainName) return null;
1121
+ const llamaProject = LIFI_TO_LLAMA_PROJECT[vault.protocol.name];
1122
+ if (!llamaProject) return null;
1123
+ let candidates = pools.filter((p) => p.project === llamaProject && p.chain === chainName);
1124
+ if (candidates.length === 0) return null;
1125
+ if (vault.underlyingTokens.length > 0) {
1126
+ const underlyingAddr = vault.underlyingTokens[0].address.toLowerCase();
1127
+ const withToken = candidates.filter((p) => p.underlyingTokens?.some((t) => t.toLowerCase() === underlyingAddr));
1128
+ if (withToken.length > 0) candidates = withToken;
1129
+ }
1130
+ const vaultName = vault.name.toUpperCase();
1131
+ const bySymbol = candidates.filter((p) => p.symbol.toUpperCase() === vaultName);
1132
+ if (bySymbol.length > 0) candidates = bySymbol;
1133
+ if (candidates.length === 0) return null;
1134
+ const vaultTvl = Number(vault.analytics.tvl.usd);
1135
+ candidates.sort((a, b) => Math.abs(a.tvlUsd - vaultTvl) - Math.abs(b.tvlUsd - vaultTvl));
1136
+ return candidates[0] ?? null;
1137
+ }
1138
+ async function getApyHistory(vaultOrAddress, chainId) {
1139
+ try {
1140
+ const pools = await fetchPools();
1141
+ if (pools.length === 0) return [];
1142
+ let pool = null;
1143
+ if (typeof vaultOrAddress === "string") {
1144
+ const chainName = CHAIN_ID_TO_LLAMA[chainId];
1145
+ if (!chainName) return [];
1146
+ const addr = vaultOrAddress.toLowerCase();
1147
+ pool = pools.find((p) => p.chain === chainName && p.underlyingTokens?.some((t) => t.toLowerCase() === addr)) ?? null;
1148
+ } else pool = matchPool(vaultOrAddress, pools);
1149
+ if (!pool) return [];
1150
+ const chartRes = await globalThis.fetch(`https://yields.llama.fi/chart/${pool.pool}`);
1151
+ if (!chartRes.ok) return [];
1152
+ return ((await chartRes.json()).data ?? []).slice(-30).map((d) => ({
1153
+ timestamp: d.timestamp,
1154
+ apy: d.apy ?? 0,
1155
+ tvlUsd: d.tvlUsd ?? 0
1156
+ }));
1157
+ } catch {
1158
+ return [];
1159
+ }
1160
+ }
1161
+ //#endregion
1162
+ //#region src/allowance.ts
1163
+ const ALLOWANCE_SELECTOR = "0xdd62ed3e";
1164
+ const APPROVE_SELECTOR = "0x095ea7b3";
1165
+ /**
1166
+ * Check the ERC-20 allowance for a token.
1167
+ *
1168
+ * @param rpcUrl - JSON-RPC endpoint for the chain
1169
+ * @param tokenAddress - ERC-20 token contract address
1170
+ * @param owner - Wallet address (token holder)
1171
+ * @param spender - Address to check allowance for (from quote.estimate.approvalAddress)
1172
+ * @param requiredAmount - Amount needed in smallest unit
1173
+ */
1174
+ async function checkAllowance(rpcUrl, tokenAddress, owner, spender, requiredAmount) {
1175
+ const calldata = `${ALLOWANCE_SELECTOR}${owner.slice(2).toLowerCase().padStart(64, "0")}${spender.slice(2).toLowerCase().padStart(64, "0")}`;
1176
+ const json = await (await globalThis.fetch(rpcUrl, {
1177
+ method: "POST",
1178
+ headers: { "Content-Type": "application/json" },
1179
+ body: JSON.stringify({
1180
+ jsonrpc: "2.0",
1181
+ method: "eth_call",
1182
+ params: [{
1183
+ to: tokenAddress,
1184
+ data: calldata
1185
+ }, "latest"],
1186
+ id: 1
1187
+ })
1188
+ })).json();
1189
+ if (json.error || !json.result) return {
1190
+ allowance: 0n,
1191
+ sufficient: false,
1192
+ requiredAmount
1193
+ };
1194
+ const allowance = BigInt(json.result);
1195
+ return {
1196
+ allowance,
1197
+ sufficient: allowance >= requiredAmount,
1198
+ requiredAmount
1199
+ };
1200
+ }
1201
+ /**
1202
+ * Build an ERC-20 approve transaction.
1203
+ *
1204
+ * @param tokenAddress - ERC-20 token contract
1205
+ * @param spender - Address to approve (from quote.estimate.approvalAddress)
1206
+ * @param amount - Amount to approve in smallest unit (use MaxUint256 for unlimited)
1207
+ * @param chainId - Chain ID for the transaction
1208
+ */
1209
+ function buildApprovalTx(tokenAddress, spender, amount, chainId) {
1210
+ return {
1211
+ to: tokenAddress,
1212
+ data: `${APPROVE_SELECTOR}${spender.slice(2).toLowerCase().padStart(64, "0")}${amount.toString(16).padStart(64, "0")}`,
1213
+ value: "0x0",
1214
+ chainId
1215
+ };
1216
+ }
1217
+ /** MaxUint256 for unlimited approval */
1218
+ const MAX_UINT256 = 2n ** 256n - 1n;
1219
+ //#endregion
1220
+ //#region src/index.ts
1221
+ /**
1222
+ * Create an EarnForge instance — the main entry point.
1223
+ *
1224
+ * ```ts
1225
+ * const forge = createEarnForge({ composerApiKey: process.env.LIFI_API_KEY });
1226
+ * for await (const vault of forge.vaults.listAll({ chainId: 8453 })) {
1227
+ * console.log(vault.name, vault.analytics.apy.total);
1228
+ * }
1229
+ * ```
1230
+ */
1231
+ function createEarnForge(options = {}) {
1232
+ const earnData = new EarnDataClient({
1233
+ cache: options.cache,
1234
+ ...options.earnData
1235
+ });
1236
+ const composer = options.composerApiKey ? new ComposerClient({
1237
+ apiKey: options.composerApiKey,
1238
+ baseUrl: options.composerBaseUrl
1239
+ }) : null;
1240
+ function requireComposer() {
1241
+ if (!composer) throw new Error("Composer API key required. Pass composerApiKey to createEarnForge() or set LIFI_API_KEY.");
1242
+ return composer;
1243
+ }
1244
+ async function getTopVaults(params = {}) {
1245
+ const limit = params.limit ?? 10;
1246
+ const strategy = params.strategy ? STRATEGIES[params.strategy] : null;
1247
+ const vaults = [];
1248
+ for await (const vault of earnData.listAllVaults({
1249
+ chainId: params.chainId,
1250
+ asset: params.asset,
1251
+ minTvl: params.minTvl
1252
+ })) {
1253
+ if (strategy) {
1254
+ const tvlUsd = parseTvl(vault.analytics.tvl).parsed;
1255
+ if (strategy.filters.minTvlUsd && tvlUsd < strategy.filters.minTvlUsd) continue;
1256
+ if (strategy.filters.tags && !strategy.filters.tags.some((t) => vault.tags.includes(t))) continue;
1257
+ if (strategy.filters.protocols && !strategy.filters.protocols.includes(vault.protocol.name)) continue;
1258
+ if (strategy.filters.minRiskScore && riskScore(vault).score < strategy.filters.minRiskScore) continue;
1259
+ }
1260
+ vaults.push(vault);
1261
+ if (vaults.length >= limit * 3) break;
1262
+ }
1263
+ vaults.sort((a, b) => b.analytics.apy.total - a.analytics.apy.total);
1264
+ return vaults.slice(0, limit);
1265
+ }
1266
+ return {
1267
+ vaults: {
1268
+ list: (params) => earnData.listVaults(params),
1269
+ listAll: (params) => earnData.listAllVaults(params),
1270
+ get: (slug) => earnData.getVaultBySlug(slug),
1271
+ top: getTopVaults
1272
+ },
1273
+ chains: { list: () => earnData.listChains() },
1274
+ protocols: { list: () => earnData.listProtocols() },
1275
+ portfolio: { get: (wallet) => earnData.getPortfolio(wallet) },
1276
+ buildDepositQuote: (vault, opts) => buildDepositQuote(vault, opts, requireComposer()),
1277
+ buildRedeemQuote: (vault, opts) => buildRedeemQuote(vault, opts, requireComposer()),
1278
+ preflight: (vault, wallet, opts) => preflight(vault, wallet, opts),
1279
+ riskScore: (vault) => riskScore(vault),
1280
+ suggest: async (params) => {
1281
+ const vaults = params.vaults ?? [];
1282
+ if (vaults.length === 0) {
1283
+ const allVaults = [];
1284
+ for await (const v of earnData.listAllVaults({ asset: params.asset })) allVaults.push(v);
1285
+ return suggest(allVaults, params);
1286
+ }
1287
+ return suggest(vaults, params);
1288
+ },
1289
+ optimizeGasRoutes: (vault, opts) => optimizeGasRoutes(vault, requireComposer(), opts),
1290
+ watch: (slug, opts) => watch(earnData, slug, opts),
1291
+ getApyHistory,
1292
+ earnDataClient: earnData,
1293
+ composerClient: composer
1294
+ };
1295
+ }
1296
+ //#endregion
1297
+ exports.AnalyticsSchema = AnalyticsSchema;
1298
+ exports.ApySchema = ApySchema;
1299
+ exports.ChainListResponseSchema = ChainListResponseSchema;
1300
+ exports.ChainSchema = ChainSchema;
1301
+ exports.ComposerClient = ComposerClient;
1302
+ exports.ComposerError = ComposerError;
1303
+ exports.ComposerTokenSchema = ComposerTokenSchema;
1304
+ exports.EarnApiError = EarnApiError;
1305
+ exports.EarnDataClient = EarnDataClient;
1306
+ exports.EarnForgeError = EarnForgeError;
1307
+ exports.FeeCostSchema = FeeCostSchema;
1308
+ exports.FeeSplitSchema = FeeSplitSchema;
1309
+ exports.GasCostSchema = GasCostSchema;
1310
+ exports.IncludedStepSchema = IncludedStepSchema;
1311
+ exports.LRUCache = LRUCache;
1312
+ exports.MAX_UINT256 = MAX_UINT256;
1313
+ exports.PackSchema = PackSchema;
1314
+ exports.PortfolioResponseSchema = PortfolioResponseSchema;
1315
+ exports.PositionAssetSchema = PositionAssetSchema;
1316
+ exports.PositionSchema = PositionSchema;
1317
+ exports.PreflightError = PreflightError;
1318
+ exports.ProtocolDetailSchema = ProtocolDetailSchema;
1319
+ exports.ProtocolListResponseSchema = ProtocolListResponseSchema;
1320
+ exports.ProtocolSchema = ProtocolSchema;
1321
+ exports.QuoteActionSchema = QuoteActionSchema;
1322
+ exports.QuoteEstimateSchema = QuoteEstimateSchema;
1323
+ exports.QuoteResponseSchema = QuoteResponseSchema;
1324
+ exports.RateLimitError = RateLimitError;
1325
+ exports.STRATEGIES = STRATEGIES;
1326
+ exports.TokenBucketRateLimiter = TokenBucketRateLimiter;
1327
+ exports.ToolDetailsSchema = ToolDetailsSchema;
1328
+ exports.TransactionRequestSchema = TransactionRequestSchema;
1329
+ exports.TvlSchema = TvlSchema;
1330
+ exports.UnderlyingTokenSchema = UnderlyingTokenSchema;
1331
+ exports.VaultListResponseSchema = VaultListResponseSchema;
1332
+ exports.VaultSchema = VaultSchema;
1333
+ exports.buildApprovalTx = buildApprovalTx;
1334
+ exports.buildDepositQuote = buildDepositQuote;
1335
+ exports.buildRedeemQuote = buildRedeemQuote;
1336
+ exports.checkAllowance = checkAllowance;
1337
+ exports.createEarnForge = createEarnForge;
1338
+ exports.fromSmallestUnit = fromSmallestUnit;
1339
+ exports.getApyHistory = getApyHistory;
1340
+ exports.getBestApy = getBestApy;
1341
+ exports.getStrategy = getStrategy;
1342
+ exports.isRetryable = isRetryable;
1343
+ exports.optimizeGasRoutes = optimizeGasRoutes;
1344
+ exports.parseTvl = parseTvl;
1345
+ exports.preflight = preflight;
1346
+ exports.riskScore = riskScore;
1347
+ exports.suggest = suggest;
1348
+ exports.toSmallestUnit = toSmallestUnit;
1349
+ exports.watch = watch;
1350
+ exports.withRetry = withRetry;