@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.
- package/dist/cjs/index.cjs +1350 -0
- package/dist/cjs/index.d.cts +2929 -0
- package/dist/cjs/index.d.cts.map +1 -0
- package/dist/esm/index.cjs +1350 -0
- package/dist/esm/index.d.cts +2929 -0
- package/dist/esm/index.d.cts.map +1 -0
- package/dist/esm/index.d.mts +2929 -0
- package/dist/esm/index.d.mts.map +1 -0
- package/dist/esm/index.mjs +1298 -0
- package/dist/esm/index.mjs.map +1 -0
- package/package.json +51 -0
|
@@ -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;
|