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