@kasufinance/kasu-sdk 2.10.3 → 2.12.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.
Files changed (41) hide show
  1. package/README.md +28 -1
  2. package/dist/bundle.cjs.js +752 -59
  3. package/dist/bundle.esm.js +750 -60
  4. package/dist/domain/index.d.ts +10 -2
  5. package/dist/domain/index.js +6 -0
  6. package/dist/domain/index.js.map +1 -1
  7. package/dist/domain/strategy-summary.d.ts +47 -0
  8. package/dist/domain/strategy-summary.js +76 -0
  9. package/dist/domain/strategy-summary.js.map +1 -0
  10. package/dist/domain/yield-history.d.ts +122 -0
  11. package/dist/domain/yield-history.js +95 -0
  12. package/dist/domain/yield-history.js.map +1 -0
  13. package/dist/facade/strategies.js +2 -0
  14. package/dist/facade/strategies.js.map +1 -1
  15. package/dist/facade/types.d.ts +13 -0
  16. package/dist/facade/user-portfolio.d.ts +32 -0
  17. package/dist/facade/user-portfolio.js +34 -0
  18. package/dist/facade/user-portfolio.js.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/services/Portfolio/portfolio.d.ts +37 -0
  22. package/dist/services/Portfolio/portfolio.js +136 -56
  23. package/dist/services/Portfolio/portfolio.js.map +1 -1
  24. package/dist/services/Portfolio/queries.js +15 -3
  25. package/dist/services/Portfolio/queries.js.map +1 -1
  26. package/dist/services/Portfolio/types.d.ts +8 -0
  27. package/dist/services/Portfolio/yield-history.d.ts +177 -0
  28. package/dist/services/Portfolio/yield-history.js +405 -0
  29. package/dist/services/Portfolio/yield-history.js.map +1 -0
  30. package/package.json +2 -1
  31. package/src/domain/index.ts +19 -10
  32. package/src/domain/strategy-summary.ts +88 -0
  33. package/src/domain/yield-history.ts +194 -0
  34. package/src/facade/strategies.ts +2 -0
  35. package/src/facade/types.ts +13 -0
  36. package/src/facade/user-portfolio.ts +40 -3
  37. package/src/index.ts +2 -0
  38. package/src/services/Portfolio/portfolio.ts +263 -153
  39. package/src/services/Portfolio/queries.ts +15 -3
  40. package/src/services/Portfolio/types.ts +10 -0
  41. package/src/services/Portfolio/yield-history.ts +574 -0
@@ -0,0 +1,574 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ import {
4
+ EpochLockShares,
5
+ epochTrancheYield,
6
+ YieldHistoryEntry,
7
+ } from '../../domain/yield-history';
8
+
9
+ /**
10
+ * Which settled epochs a history covers. Epochs are counted in the chain's
11
+ * numbering (`SystemVariables.currentEpochNumber`). Only SETTLED epochs exist
12
+ * in a history — the newest is `currentEpoch − 1`.
13
+ *
14
+ * - neither field: every settled epoch;
15
+ * - `count` only: the newest `count` settled epochs;
16
+ * - `fromEpoch` only: `fromEpoch` up to the newest settled epoch;
17
+ * - both: `count` epochs starting at `fromEpoch`, capped at the newest settled.
18
+ */
19
+ export interface YieldHistoryOptions {
20
+ /** First epoch included. A non-negative integer. */
21
+ fromEpoch?: number;
22
+ /** How many epochs. A positive integer. */
23
+ count?: number;
24
+ }
25
+
26
+ /** The resolved, inclusive epoch range; `null` when it holds no settled epoch. */
27
+ export interface YieldHistoryWindow {
28
+ fromEpoch: number;
29
+ toEpoch: number;
30
+ }
31
+
32
+ /** The subgraph returns at most this many rows per collection per request. */
33
+ export const YIELD_HISTORY_PAGE_SIZE = 1000;
34
+
35
+ /**
36
+ * A runaway guard, not a working limit: a collection this many pages long
37
+ * (`MAX × PAGE_SIZE` rows for one lender) is a broken response, not a history.
38
+ */
39
+ export const YIELD_HISTORY_MAX_PAGES = 100;
40
+
41
+ /**
42
+ * Throws `RangeError` on a malformed `fromEpoch` / `count`. `getYieldHistory`
43
+ * calls it BEFORE any network read.
44
+ */
45
+ export function validateYieldHistoryOptions(
46
+ options: YieldHistoryOptions,
47
+ ): void {
48
+ const { fromEpoch, count } = options;
49
+ if (
50
+ fromEpoch !== undefined &&
51
+ (!Number.isSafeInteger(fromEpoch) || fromEpoch < 0)
52
+ ) {
53
+ throw new RangeError(
54
+ `getYieldHistory: fromEpoch must be a non-negative integer, got ${String(fromEpoch)}`,
55
+ );
56
+ }
57
+ if (count !== undefined && (!Number.isSafeInteger(count) || count < 1)) {
58
+ throw new RangeError(
59
+ `getYieldHistory: count must be a positive integer, got ${String(count)}`,
60
+ );
61
+ }
62
+ }
63
+
64
+ export function resolveYieldHistoryWindow(
65
+ options: YieldHistoryOptions,
66
+ currentEpoch: number,
67
+ ): YieldHistoryWindow | null {
68
+ validateYieldHistoryOptions(options);
69
+ const { fromEpoch, count } = options;
70
+
71
+ const newest = currentEpoch - 1;
72
+ if (newest < 0) return null;
73
+
74
+ let from: number;
75
+ let to = newest;
76
+ if (fromEpoch !== undefined) {
77
+ from = fromEpoch;
78
+ if (count !== undefined) to = Math.min(newest, fromEpoch + count - 1);
79
+ } else {
80
+ from = count !== undefined ? Math.max(0, newest - count + 1) : 0;
81
+ }
82
+
83
+ return from > to ? null : { fromEpoch: from, toEpoch: to };
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Subgraph reads
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Round trip 1: the lender's tranche positions and fixed-term locks. Same
92
+ * pool exclusion and the same nested-list size (`$first`) as
93
+ * `lendingPortfolioQuery`, so both read the same lock set.
94
+ */
95
+ export const yieldHistoryPositionsQuery = gql`
96
+ query YieldHistoryPositions(
97
+ $userAddress: ID!
98
+ $unusedPools: [String!]!
99
+ $first: Int!
100
+ ) {
101
+ user(id: $userAddress) {
102
+ lendingPoolUserDetails(
103
+ first: $first
104
+ where: { lendingPool_not_in: $unusedPools }
105
+ ) {
106
+ lendingPool {
107
+ id
108
+ }
109
+ lendingPoolTrancheUserDetails(first: $first) {
110
+ id
111
+ tranche {
112
+ id
113
+ balance
114
+ shares
115
+ }
116
+ userLendingPoolTrancheFixedTermDepositLocks(first: $first) {
117
+ id
118
+ epochLockStart
119
+ epochLockEnd
120
+ initialTrancheShares
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+ `;
127
+
128
+ /**
129
+ * Round trip 2 onwards: the four per-epoch series, one page of each per
130
+ * request, cursor-paginated by `id`. A series that is exhausted is skipped with
131
+ * `@include`, so a history costs as many requests as its LONGEST series has
132
+ * pages — normally one — never one per epoch.
133
+ *
134
+ * Share snapshots are read up to the window's end with no lower bound: the
135
+ * value AS OF the first epoch may come from an update long before it.
136
+ */
137
+ export const yieldHistoryPageQuery = gql`
138
+ query YieldHistoryPage(
139
+ $tranches: [String!]!
140
+ $userTranches: [String!]!
141
+ $locks: [String!]!
142
+ $fromEpoch: BigInt!
143
+ $toEpoch: BigInt!
144
+ $interestCursor: ID!
145
+ $trancheSharesCursor: ID!
146
+ $userSharesCursor: ID!
147
+ $lockSharesCursor: ID!
148
+ $withInterest: Boolean!
149
+ $withTrancheShares: Boolean!
150
+ $withUserShares: Boolean!
151
+ $withLockShares: Boolean!
152
+ $first: Int!
153
+ ) {
154
+ interest: lendingPoolTrancheEpochInterests(
155
+ first: $first
156
+ orderBy: id
157
+ orderDirection: asc
158
+ where: {
159
+ id_gt: $interestCursor
160
+ lendingPoolTranche_in: $tranches
161
+ epochId_gte: $fromEpoch
162
+ epochId_lte: $toEpoch
163
+ }
164
+ ) @include(if: $withInterest) {
165
+ id
166
+ epochId
167
+ epochInterestAmount
168
+ lendingPoolTranche {
169
+ id
170
+ }
171
+ }
172
+ trancheShares: lendingPoolTrancheShareUpdates(
173
+ first: $first
174
+ orderBy: id
175
+ orderDirection: asc
176
+ where: {
177
+ id_gt: $trancheSharesCursor
178
+ lendingPoolTranche_in: $tranches
179
+ epochId_lte: $toEpoch
180
+ }
181
+ ) @include(if: $withTrancheShares) {
182
+ id
183
+ epochId
184
+ shareUpdatesIndex
185
+ shares
186
+ lendingPoolTranche {
187
+ id
188
+ }
189
+ }
190
+ userShares: lendingPoolTrancheUserEpochSharesUpdates(
191
+ first: $first
192
+ orderBy: id
193
+ orderDirection: asc
194
+ where: {
195
+ id_gt: $userSharesCursor
196
+ lendingPoolTrancheUserDetails_in: $userTranches
197
+ epochId_lte: $toEpoch
198
+ }
199
+ ) @include(if: $withUserShares) {
200
+ id
201
+ epochId
202
+ shareUpdatesIndex
203
+ shares
204
+ lendingPoolTrancheUserDetails {
205
+ id
206
+ }
207
+ }
208
+ lockShares: userLendingPoolTrancheFtdLockShareUpdates(
209
+ first: $first
210
+ orderBy: id
211
+ orderDirection: asc
212
+ where: {
213
+ id_gt: $lockSharesCursor
214
+ userLendingPoolTrancheFixedTermDepositLock_in: $locks
215
+ epochId_lte: $toEpoch
216
+ }
217
+ ) @include(if: $withLockShares) {
218
+ id
219
+ epochId
220
+ shareUpdatesIndex
221
+ shares
222
+ userLendingPoolTrancheFixedTermDepositLock {
223
+ id
224
+ }
225
+ }
226
+ }
227
+ `;
228
+
229
+ export interface YieldHistoryPositionsResult {
230
+ user: {
231
+ lendingPoolUserDetails: {
232
+ lendingPool: { id: string };
233
+ lendingPoolTrancheUserDetails: {
234
+ id: string;
235
+ tranche: { id: string; balance: string; shares: string };
236
+ userLendingPoolTrancheFixedTermDepositLocks: {
237
+ id: string;
238
+ epochLockStart: string;
239
+ epochLockEnd: string;
240
+ initialTrancheShares: string;
241
+ }[];
242
+ }[];
243
+ }[];
244
+ } | null;
245
+ }
246
+
247
+ /** One share snapshot: the value from `epochId` on, until the next one. */
248
+ export interface ShareUpdateRow {
249
+ id: string;
250
+ epochId: string;
251
+ shareUpdatesIndex: number;
252
+ shares: string;
253
+ }
254
+
255
+ export interface InterestRow {
256
+ id: string;
257
+ epochId: string;
258
+ epochInterestAmount: string;
259
+ lendingPoolTranche: { id: string };
260
+ }
261
+
262
+ export interface YieldHistoryPageResult {
263
+ interest?: InterestRow[];
264
+ trancheShares?: (ShareUpdateRow & { lendingPoolTranche: { id: string } })[];
265
+ userShares?: (ShareUpdateRow & {
266
+ lendingPoolTrancheUserDetails: { id: string };
267
+ })[];
268
+ lockShares?: (ShareUpdateRow & {
269
+ userLendingPoolTrancheFixedTermDepositLock: { id: string };
270
+ })[];
271
+ }
272
+
273
+ /** Everything `buildYieldHistory` reads, keyed by the subgraph's entity ids. */
274
+ export interface YieldHistoryRead {
275
+ positions: YieldHistoryPositionsResult;
276
+ interest: InterestRow[];
277
+ trancheShares: (ShareUpdateRow & { lendingPoolTranche: { id: string } })[];
278
+ userShares: (ShareUpdateRow & {
279
+ lendingPoolTrancheUserDetails: { id: string };
280
+ })[];
281
+ lockShares: (ShareUpdateRow & {
282
+ userLendingPoolTrancheFixedTermDepositLock: { id: string };
283
+ })[];
284
+ }
285
+
286
+ /** The chain's epoch clock: epoch E ends at `epochZeroStart + (E + 1) × duration`. */
287
+ export interface EpochClock {
288
+ epochZeroStart: number;
289
+ epochDuration: number;
290
+ }
291
+
292
+ type PageRequest = (
293
+ variables: Record<string, unknown>,
294
+ ) => Promise<YieldHistoryPageResult>;
295
+
296
+ /**
297
+ * Fetch the four series for the window, paginating each by `id` until a short
298
+ * page. Returns the rows and how many requests it took.
299
+ */
300
+ export async function fetchYieldHistorySeries(
301
+ request: PageRequest,
302
+ ids: { tranches: string[]; userTranches: string[]; locks: string[] },
303
+ window: YieldHistoryWindow,
304
+ ): Promise<{
305
+ rows: Omit<YieldHistoryRead, 'positions'>;
306
+ requests: number;
307
+ }> {
308
+ const rows: Omit<YieldHistoryRead, 'positions'> = {
309
+ interest: [],
310
+ trancheShares: [],
311
+ userShares: [],
312
+ lockShares: [],
313
+ };
314
+ const series = [
315
+ { key: 'interest', flag: 'withInterest', cursor: 'interestCursor' },
316
+ {
317
+ key: 'trancheShares',
318
+ flag: 'withTrancheShares',
319
+ cursor: 'trancheSharesCursor',
320
+ },
321
+ {
322
+ key: 'userShares',
323
+ flag: 'withUserShares',
324
+ cursor: 'userSharesCursor',
325
+ },
326
+ {
327
+ key: 'lockShares',
328
+ flag: 'withLockShares',
329
+ cursor: 'lockSharesCursor',
330
+ },
331
+ ] as const;
332
+
333
+ const cursors: Record<(typeof series)[number]['key'], string> = {
334
+ interest: '',
335
+ trancheShares: '',
336
+ userShares: '',
337
+ lockShares: '',
338
+ };
339
+ const open: Record<(typeof series)[number]['key'], boolean> = {
340
+ interest: ids.tranches.length > 0,
341
+ trancheShares: ids.tranches.length > 0,
342
+ userShares: ids.userTranches.length > 0,
343
+ lockShares: ids.locks.length > 0,
344
+ };
345
+
346
+ let requests = 0;
347
+ while (series.some(({ key }) => open[key])) {
348
+ if (requests >= YIELD_HISTORY_MAX_PAGES) {
349
+ throw new Error(
350
+ `getYieldHistory: more than ${String(YIELD_HISTORY_MAX_PAGES)} pages for one lender`,
351
+ );
352
+ }
353
+
354
+ const variables: Record<string, unknown> = {
355
+ tranches: ids.tranches,
356
+ userTranches: ids.userTranches,
357
+ locks: ids.locks,
358
+ fromEpoch: String(window.fromEpoch),
359
+ toEpoch: String(window.toEpoch),
360
+ first: YIELD_HISTORY_PAGE_SIZE,
361
+ };
362
+ for (const { key, flag, cursor } of series) {
363
+ variables[flag] = open[key];
364
+ variables[cursor] = cursors[key];
365
+ }
366
+
367
+ const page = await request(variables);
368
+ requests += 1;
369
+
370
+ for (const { key } of series) {
371
+ if (!open[key]) continue;
372
+ const got = page[key] ?? [];
373
+ (rows[key] as { id: string }[]).push(...got);
374
+ const last = got.at(-1);
375
+ if (got.length < YIELD_HISTORY_PAGE_SIZE || last === undefined) {
376
+ open[key] = false;
377
+ continue;
378
+ }
379
+ if (last.id <= cursors[key]) {
380
+ throw new Error(
381
+ `getYieldHistory: the ${key} cursor did not advance`,
382
+ );
383
+ }
384
+ cursors[key] = last.id;
385
+ }
386
+ }
387
+
388
+ return { rows, requests };
389
+ }
390
+
391
+ /**
392
+ * The snapshot in force at `epoch`: among updates with `epochId ≤ epoch`, the
393
+ * one with the highest `shareUpdatesIndex` — exactly what
394
+ * `lendingPortfolioQuery` selects with `orderBy: shareUpdatesIndex, desc,
395
+ * first: 1, where: { epochId_lt: epoch + 1 }`. `undefined` when none.
396
+ */
397
+ export function sharesAsOf(
398
+ updates: readonly ShareUpdateRow[],
399
+ epoch: number,
400
+ ): ShareUpdateRow | undefined {
401
+ let found: ShareUpdateRow | undefined;
402
+ for (const update of updates) {
403
+ if (Number(update.epochId) > epoch) continue;
404
+ if (!found || update.shareUpdatesIndex > found.shareUpdatesIndex) {
405
+ found = update;
406
+ }
407
+ }
408
+ return found;
409
+ }
410
+
411
+ function groupBy<T>(
412
+ rows: readonly T[],
413
+ key: (row: T) => string,
414
+ ): Map<string, T[]> {
415
+ const map = new Map<string, T[]>();
416
+ for (const row of rows) {
417
+ const k = key(row);
418
+ const list = map.get(k);
419
+ if (list) list.push(row);
420
+ else map.set(k, [row]);
421
+ }
422
+ return map;
423
+ }
424
+
425
+ /**
426
+ * Pure: the fetched series → one entry per pool, tranche and settled epoch in
427
+ * which the lender held shares (or a lock earned a top-up). Each entry runs the
428
+ * same `epochTrancheYield` as the portfolio's last-epoch figure, with the same
429
+ * share selection, so the newest entry equals that figure.
430
+ *
431
+ * Sorted by `epochId`, then `poolId`, then `trancheId`.
432
+ */
433
+ export function buildYieldHistory(
434
+ read: YieldHistoryRead,
435
+ window: YieldHistoryWindow,
436
+ clock: EpochClock,
437
+ ): YieldHistoryEntry[] {
438
+ const user = read.positions.user;
439
+ if (!user) return [];
440
+
441
+ const interestByTranche = groupBy(
442
+ read.interest,
443
+ (row) => row.lendingPoolTranche.id,
444
+ );
445
+ const trancheSharesByTranche = groupBy(
446
+ read.trancheShares,
447
+ (row) => row.lendingPoolTranche.id,
448
+ );
449
+ const userSharesByPosition = groupBy(
450
+ read.userShares,
451
+ (row) => row.lendingPoolTrancheUserDetails.id,
452
+ );
453
+ const lockSharesByLock = groupBy(
454
+ read.lockShares,
455
+ (row) => row.userLendingPoolTrancheFixedTermDepositLock.id,
456
+ );
457
+
458
+ const entries: YieldHistoryEntry[] = [];
459
+
460
+ for (const pool of user.lendingPoolUserDetails) {
461
+ for (const position of pool.lendingPoolTrancheUserDetails) {
462
+ const trancheId = position.tranche.id;
463
+ const trancheUpdates = trancheSharesByTranche.get(trancheId) ?? [];
464
+ const userUpdates = userSharesByPosition.get(position.id) ?? [];
465
+ const trancheBalanceNow = parseFloat(position.tranche.balance);
466
+ const trancheSharesNow = parseFloat(position.tranche.shares);
467
+
468
+ for (const record of interestByTranche.get(trancheId) ?? []) {
469
+ const epoch = Number(record.epochId);
470
+ if (epoch < window.fromEpoch || epoch > window.toEpoch) {
471
+ continue;
472
+ }
473
+
474
+ const userSnapshot = sharesAsOf(userUpdates, epoch);
475
+ const userShares = parseFloat(userSnapshot?.shares ?? '0');
476
+
477
+ const locks: EpochLockShares[] =
478
+ position.userLendingPoolTrancheFixedTermDepositLocks.map(
479
+ (lock) => {
480
+ const lockUpdates =
481
+ lockSharesByLock.get(lock.id) ?? [];
482
+ return {
483
+ active:
484
+ parseFloat(lock.epochLockStart) < epoch &&
485
+ parseFloat(lock.epochLockEnd) >= epoch &&
486
+ userSnapshot !== undefined,
487
+ // No snapshot yet = the lock's opening
488
+ // shares: a lock made after clearing E
489
+ // starts at E but its first update is
490
+ // tagged E + 1, and reading that gap as 0
491
+ // would book its principal as a top-up.
492
+ sharesAtEpoch: parseFloat(
493
+ sharesAsOf(lockUpdates, epoch)?.shares ??
494
+ lock.initialTrancheShares,
495
+ ),
496
+ sharesBeforeEpoch: parseFloat(
497
+ sharesAsOf(lockUpdates, epoch - 1)
498
+ ?.shares ?? lock.initialTrancheShares,
499
+ ),
500
+ };
501
+ },
502
+ );
503
+
504
+ const { total } = epochTrancheYield({
505
+ epochInterest: parseFloat(record.epochInterestAmount),
506
+ userShares,
507
+ trancheShares: parseFloat(
508
+ sharesAsOf(trancheUpdates, epoch)?.shares ?? '0',
509
+ ),
510
+ trancheBalanceNow,
511
+ trancheSharesNow,
512
+ locks,
513
+ });
514
+
515
+ // Nothing held and nothing topped up: not part of this
516
+ // lender's history (a new lender's history is empty).
517
+ if (userShares <= 0 && total === 0) continue;
518
+
519
+ entries.push({
520
+ epochId: epoch,
521
+ epochEnd:
522
+ clock.epochZeroStart +
523
+ (epoch + 1) * clock.epochDuration,
524
+ poolId: pool.lendingPool.id,
525
+ trancheId,
526
+ netYield: total,
527
+ });
528
+ }
529
+ }
530
+ }
531
+
532
+ return entries.sort(
533
+ (a, b) =>
534
+ a.epochId - b.epochId ||
535
+ a.poolId.localeCompare(b.poolId) ||
536
+ a.trancheId.localeCompare(b.trancheId),
537
+ );
538
+ }
539
+
540
+ /**
541
+ * A GraphQL VALIDATION error naming an entity, field or argument this
542
+ * deployment's subgraph does not have. A network error, a timeout or an
543
+ * indexing error is not one.
544
+ */
545
+ export function isMissingEntityError(error: unknown): boolean {
546
+ if (typeof error !== 'object' || error === null) return false;
547
+ const response = (error as { response?: { errors?: unknown } }).response;
548
+ const errors = response?.errors;
549
+ if (!Array.isArray(errors) || errors.length === 0) return false;
550
+ return errors.every((e: unknown) => {
551
+ const message = (e as { message?: unknown } | null)?.message;
552
+ return (
553
+ typeof message === 'string' &&
554
+ /has no field|cannot query field|unknown (type|field|argument)|no such (type|field)/i.test(
555
+ message,
556
+ )
557
+ );
558
+ });
559
+ }
560
+
561
+ /**
562
+ * The only failure `getYieldHistory` turns into `[]`: a missing-schema error
563
+ * on a LITE deployment, whose subgraph may predate the entities. On a full
564
+ * deployment the entities are part of the schema, so the same error means a
565
+ * broken query or subgraph and THROWS — an empty chart there would hide it.
566
+ * Everything else (network, timeout, indexing) throws on every deployment, so
567
+ * a consumer can tell "no history here" from "history unavailable".
568
+ */
569
+ export function isEmptyHistoryError(
570
+ error: unknown,
571
+ liteDeployment: boolean,
572
+ ): boolean {
573
+ return liteDeployment && isMissingEntityError(error);
574
+ }