@kasufinance/kasu-sdk 2.15.0 → 2.17.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 (42) hide show
  1. package/README.md +17 -1
  2. package/dist/bundle.cjs.js +1144 -221
  3. package/dist/bundle.esm.js +1141 -222
  4. package/dist/domain/index.d.ts +2 -2
  5. package/dist/domain/index.js +1 -1
  6. package/dist/domain/index.js.map +1 -1
  7. package/dist/domain/yield-history.d.ts +25 -1
  8. package/dist/domain/yield-history.js +41 -11
  9. package/dist/domain/yield-history.js.map +1 -1
  10. package/dist/domain/yields.d.ts +200 -15
  11. package/dist/domain/yields.js +211 -14
  12. package/dist/domain/yields.js.map +1 -1
  13. package/dist/services/Locking/locking.d.ts +12 -0
  14. package/dist/services/Locking/locking.js +16 -0
  15. package/dist/services/Locking/locking.js.map +1 -1
  16. package/dist/services/Portfolio/portfolio.d.ts +54 -1
  17. package/dist/services/Portfolio/portfolio.js +357 -80
  18. package/dist/services/Portfolio/portfolio.js.map +1 -1
  19. package/dist/services/Portfolio/queries.d.ts +16 -0
  20. package/dist/services/Portfolio/queries.js +119 -2
  21. package/dist/services/Portfolio/queries.js.map +1 -1
  22. package/dist/services/Portfolio/types.d.ts +94 -6
  23. package/dist/services/Portfolio/yield-history.d.ts +148 -10
  24. package/dist/services/Portfolio/yield-history.js +241 -31
  25. package/dist/services/Portfolio/yield-history.js.map +1 -1
  26. package/dist/services/UserLending/user-lending.d.ts +16 -0
  27. package/dist/services/UserLending/user-lending.js +24 -0
  28. package/dist/services/UserLending/user-lending.js.map +1 -1
  29. package/dist/services/shared.d.ts +7 -0
  30. package/dist/services/shared.js +48 -0
  31. package/dist/services/shared.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/domain/index.ts +5 -1
  34. package/src/domain/yield-history.ts +47 -11
  35. package/src/domain/yields.ts +257 -15
  36. package/src/services/Locking/locking.ts +17 -0
  37. package/src/services/Portfolio/portfolio.ts +559 -126
  38. package/src/services/Portfolio/queries.ts +119 -2
  39. package/src/services/Portfolio/types.ts +91 -6
  40. package/src/services/Portfolio/yield-history.ts +405 -56
  41. package/src/services/UserLending/user-lending.ts +46 -0
  42. package/src/services/shared.ts +48 -0
@@ -2,6 +2,7 @@ import { gql } from 'graphql-request';
2
2
 
3
3
  import {
4
4
  EpochLockShares,
5
+ EpochTrancheYield,
5
6
  epochTrancheYield,
6
7
  YieldHistoryEntry,
7
8
  } from '../../domain/yield-history';
@@ -108,6 +109,7 @@ export const yieldHistoryPositionsQuery = gql`
108
109
  }
109
110
  lendingPoolTrancheUserDetails(first: $first) {
110
111
  id
112
+ shares
111
113
  tranche {
112
114
  id
113
115
  balance
@@ -122,33 +124,51 @@ export const yieldHistoryPositionsQuery = gql`
122
124
  }
123
125
  }
124
126
  }
127
+ _meta {
128
+ block {
129
+ number
130
+ }
131
+ }
125
132
  }
126
133
  `;
127
134
 
128
135
  /**
129
- * Round trip 2 onwards: the four per-epoch series, one page of each per
136
+ * Round trip 2 onwards: the six per-epoch series, one page of each per
130
137
  * request, cursor-paginated by `id`. A series that is exhausted is skipped with
131
138
  * `@include`, so a history costs as many requests as its LONGEST series has
132
139
  * pages — normally one — never one per epoch.
133
140
  *
134
141
  * 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.
142
+ * value AS OF the first epoch may come from an update long before it. Each
143
+ * carries its `timestamp`, and the window's clearings their start, because a
144
+ * cycle's interest is paired with the shares in force when the clearing
145
+ * APPLIED it (`sharesAtInterest`), which the epoch tag alone gets wrong for a
146
+ * withdrawal accepted between clearings — so the lender's updates are read one
147
+ * tag past the window (`$userSharesToEpoch`), where the subgraph puts it.
148
+ * Every share exit between clearings is read too (`trancheSharesAtInterest`
149
+ * rebuilds the tranche total from the previous clearing's snapshot).
136
150
  */
137
151
  export const yieldHistoryPageQuery = gql`
138
152
  query YieldHistoryPage(
153
+ $pools: [String!]!
139
154
  $tranches: [String!]!
140
155
  $userTranches: [String!]!
141
156
  $locks: [String!]!
142
157
  $fromEpoch: BigInt!
143
158
  $toEpoch: BigInt!
159
+ $userSharesToEpoch: BigInt!
144
160
  $interestCursor: ID!
145
161
  $trancheSharesCursor: ID!
146
162
  $userSharesCursor: ID!
147
163
  $lockSharesCursor: ID!
164
+ $clearingsCursor: ID!
165
+ $withdrawalsCursor: ID!
148
166
  $withInterest: Boolean!
149
167
  $withTrancheShares: Boolean!
150
168
  $withUserShares: Boolean!
151
169
  $withLockShares: Boolean!
170
+ $withClearings: Boolean!
171
+ $withWithdrawals: Boolean!
152
172
  $first: Int!
153
173
  ) {
154
174
  interest: lendingPoolTrancheEpochInterests(
@@ -183,6 +203,7 @@ export const yieldHistoryPageQuery = gql`
183
203
  epochId
184
204
  shareUpdatesIndex
185
205
  shares
206
+ timestamp
186
207
  lendingPoolTranche {
187
208
  id
188
209
  }
@@ -194,13 +215,14 @@ export const yieldHistoryPageQuery = gql`
194
215
  where: {
195
216
  id_gt: $userSharesCursor
196
217
  lendingPoolTrancheUserDetails_in: $userTranches
197
- epochId_lte: $toEpoch
218
+ epochId_lte: $userSharesToEpoch
198
219
  }
199
220
  ) @include(if: $withUserShares) {
200
221
  id
201
222
  epochId
202
223
  shareUpdatesIndex
203
224
  shares
225
+ timestamp
204
226
  lendingPoolTrancheUserDetails {
205
227
  id
206
228
  }
@@ -223,6 +245,45 @@ export const yieldHistoryPageQuery = gql`
223
245
  id
224
246
  }
225
247
  }
248
+ clearings: lendingPoolClearings(
249
+ first: $first
250
+ orderBy: id
251
+ orderDirection: asc
252
+ where: {
253
+ id_gt: $clearingsCursor
254
+ lendingPool_in: $pools
255
+ epochId_gte: $fromEpoch
256
+ epochId_lte: $toEpoch
257
+ }
258
+ ) @include(if: $withClearings) {
259
+ id
260
+ epochId
261
+ startTimestamp
262
+ lendingPool {
263
+ id
264
+ }
265
+ }
266
+ withdrawals: userRequestEvents(
267
+ first: $first
268
+ orderBy: id
269
+ orderDirection: asc
270
+ where: {
271
+ id_gt: $withdrawalsCursor
272
+ type_in: [WithdrawalAccepted, ImmediateWithdrawalExecuted]
273
+ tranche_in: $tranches
274
+ epochId_gte: $fromEpoch
275
+ epochId_lte: $toEpoch
276
+ }
277
+ ) @include(if: $withWithdrawals) {
278
+ id
279
+ type
280
+ epochId
281
+ createdOn
282
+ sharesAmount
283
+ tranche {
284
+ id
285
+ }
286
+ }
226
287
  }
227
288
  `;
228
289
 
@@ -232,6 +293,8 @@ export interface YieldHistoryPositionsResult {
232
293
  lendingPool: { id: string };
233
294
  lendingPoolTrancheUserDetails: {
234
295
  id: string;
296
+ /** The ledger's share count (accepted deposits − withdrawals ± top-ups). */
297
+ shares?: string;
235
298
  tranche: { id: string; balance: string; shares: string };
236
299
  userLendingPoolTrancheFixedTermDepositLocks: {
237
300
  id: string;
@@ -242,6 +305,8 @@ export interface YieldHistoryPositionsResult {
242
305
  }[];
243
306
  }[];
244
307
  } | null;
308
+ /** The block the subgraph answered at. */
309
+ _meta?: { block: { number: number } };
245
310
  }
246
311
 
247
312
  /** One share snapshot: the value from `epochId` on, until the next one. */
@@ -252,6 +317,18 @@ export interface ShareUpdateRow {
252
317
  shares: string;
253
318
  }
254
319
 
320
+ /** A tranche or lender share snapshot, with the block time it was written at. */
321
+ export interface TimedShareUpdateRow extends ShareUpdateRow {
322
+ /** Unix seconds. */
323
+ timestamp: string;
324
+ }
325
+
326
+ /** What the share selection reads from a snapshot — no entity id needed. */
327
+ export type TimedShareSnapshot = Pick<
328
+ TimedShareUpdateRow,
329
+ 'epochId' | 'shareUpdatesIndex' | 'shares' | 'timestamp'
330
+ >;
331
+
255
332
  export interface InterestRow {
256
333
  id: string;
257
334
  epochId: string;
@@ -259,28 +336,60 @@ export interface InterestRow {
259
336
  lendingPoolTranche: { id: string };
260
337
  }
261
338
 
339
+ /** A pool's clearing for one epoch: its first transaction applied the interest. */
340
+ export interface ClearingRow {
341
+ id: string;
342
+ epochId: string;
343
+ /** Unix seconds of the clearing's first transaction. */
344
+ startTimestamp: string;
345
+ lendingPool: { id: string };
346
+ }
347
+
348
+ /**
349
+ * Shares leaving a tranche (any lender): an accepted withdrawal or an
350
+ * immediate one. Each carries the pool's clearing epoch at the time
351
+ * (`epochClearingId`) — so one between clearings E − 1 and E is tagged E.
352
+ */
353
+ export interface ShareExitRow {
354
+ id: string;
355
+ type: 'WithdrawalAccepted' | 'ImmediateWithdrawalExecuted';
356
+ epochId: string;
357
+ /** Unix seconds. */
358
+ createdOn: string;
359
+ sharesAmount: string | null;
360
+ tranche: { id: string };
361
+ }
362
+
262
363
  export interface YieldHistoryPageResult {
263
364
  interest?: InterestRow[];
264
- trancheShares?: (ShareUpdateRow & { lendingPoolTranche: { id: string } })[];
265
- userShares?: (ShareUpdateRow & {
365
+ trancheShares?: (TimedShareUpdateRow & {
366
+ lendingPoolTranche: { id: string };
367
+ })[];
368
+ userShares?: (TimedShareUpdateRow & {
266
369
  lendingPoolTrancheUserDetails: { id: string };
267
370
  })[];
268
371
  lockShares?: (ShareUpdateRow & {
269
372
  userLendingPoolTrancheFixedTermDepositLock: { id: string };
270
373
  })[];
374
+ clearings?: ClearingRow[];
375
+ withdrawals?: ShareExitRow[];
271
376
  }
272
377
 
273
378
  /** Everything `buildYieldHistory` reads, keyed by the subgraph's entity ids. */
274
379
  export interface YieldHistoryRead {
275
380
  positions: YieldHistoryPositionsResult;
276
381
  interest: InterestRow[];
277
- trancheShares: (ShareUpdateRow & { lendingPoolTranche: { id: string } })[];
278
- userShares: (ShareUpdateRow & {
382
+ trancheShares: (TimedShareUpdateRow & {
383
+ lendingPoolTranche: { id: string };
384
+ })[];
385
+ userShares: (TimedShareUpdateRow & {
279
386
  lendingPoolTrancheUserDetails: { id: string };
280
387
  })[];
281
388
  lockShares: (ShareUpdateRow & {
282
389
  userLendingPoolTrancheFixedTermDepositLock: { id: string };
283
390
  })[];
391
+ clearings: ClearingRow[];
392
+ withdrawals: ShareExitRow[];
284
393
  }
285
394
 
286
395
  /** The chain's epoch clock: epoch E ends at `epochZeroStart + (E + 1) × duration`. */
@@ -294,12 +403,17 @@ type PageRequest = (
294
403
  ) => Promise<YieldHistoryPageResult>;
295
404
 
296
405
  /**
297
- * Fetch the four series for the window, paginating each by `id` until a short
406
+ * Fetch the six series for the window, paginating each by `id` until a short
298
407
  * page. Returns the rows and how many requests it took.
299
408
  */
300
409
  export async function fetchYieldHistorySeries(
301
410
  request: PageRequest,
302
- ids: { tranches: string[]; userTranches: string[]; locks: string[] },
411
+ ids: {
412
+ pools: string[];
413
+ tranches: string[];
414
+ userTranches: string[];
415
+ locks: string[];
416
+ },
303
417
  window: YieldHistoryWindow,
304
418
  ): Promise<{
305
419
  rows: Omit<YieldHistoryRead, 'positions'>;
@@ -310,6 +424,8 @@ export async function fetchYieldHistorySeries(
310
424
  trancheShares: [],
311
425
  userShares: [],
312
426
  lockShares: [],
427
+ clearings: [],
428
+ withdrawals: [],
313
429
  };
314
430
  const series = [
315
431
  { key: 'interest', flag: 'withInterest', cursor: 'interestCursor' },
@@ -328,6 +444,16 @@ export async function fetchYieldHistorySeries(
328
444
  flag: 'withLockShares',
329
445
  cursor: 'lockSharesCursor',
330
446
  },
447
+ {
448
+ key: 'clearings',
449
+ flag: 'withClearings',
450
+ cursor: 'clearingsCursor',
451
+ },
452
+ {
453
+ key: 'withdrawals',
454
+ flag: 'withWithdrawals',
455
+ cursor: 'withdrawalsCursor',
456
+ },
331
457
  ] as const;
332
458
 
333
459
  const cursors: Record<(typeof series)[number]['key'], string> = {
@@ -335,12 +461,16 @@ export async function fetchYieldHistorySeries(
335
461
  trancheShares: '',
336
462
  userShares: '',
337
463
  lockShares: '',
464
+ clearings: '',
465
+ withdrawals: '',
338
466
  };
339
467
  const open: Record<(typeof series)[number]['key'], boolean> = {
340
468
  interest: ids.tranches.length > 0,
341
469
  trancheShares: ids.tranches.length > 0,
342
470
  userShares: ids.userTranches.length > 0,
343
471
  lockShares: ids.locks.length > 0,
472
+ clearings: ids.pools.length > 0,
473
+ withdrawals: ids.tranches.length > 0,
344
474
  };
345
475
 
346
476
  let requests = 0;
@@ -352,11 +482,15 @@ export async function fetchYieldHistorySeries(
352
482
  }
353
483
 
354
484
  const variables: Record<string, unknown> = {
485
+ pools: ids.pools,
355
486
  tranches: ids.tranches,
356
487
  userTranches: ids.userTranches,
357
488
  locks: ids.locks,
358
489
  fromEpoch: String(window.fromEpoch),
359
490
  toEpoch: String(window.toEpoch),
491
+ // One tag further for the lender: a withdrawal accepted between
492
+ // clearings is tagged with the epoch AFTER the one it counts in.
493
+ userSharesToEpoch: String(window.toEpoch + 1),
360
494
  first: YIELD_HISTORY_PAGE_SIZE,
361
495
  };
362
496
  for (const { key, flag, cursor } of series) {
@@ -394,11 +528,10 @@ export async function fetchYieldHistorySeries(
394
528
  * `lendingPortfolioQuery` selects with `orderBy: shareUpdatesIndex, desc,
395
529
  * first: 1, where: { epochId_lt: epoch + 1 }`. `undefined` when none.
396
530
  */
397
- export function sharesAsOf(
398
- updates: readonly ShareUpdateRow[],
399
- epoch: number,
400
- ): ShareUpdateRow | undefined {
401
- let found: ShareUpdateRow | undefined;
531
+ export function sharesAsOf<
532
+ T extends Pick<ShareUpdateRow, 'epochId' | 'shareUpdatesIndex' | 'shares'>,
533
+ >(updates: readonly T[], epoch: number): T | undefined {
534
+ let found: T | undefined;
402
535
  for (const update of updates) {
403
536
  if (Number(update.epochId) > epoch) continue;
404
537
  if (!found || update.shareUpdatesIndex > found.shareUpdatesIndex) {
@@ -408,6 +541,233 @@ export function sharesAsOf(
408
541
  return found;
409
542
  }
410
543
 
544
+ /**
545
+ * The snapshot in force when the clearing of `epoch` APPLIED that epoch's
546
+ * interest — the shares the interest was actually spread over.
547
+ *
548
+ * A clearing applies interest FIRST (`ClearingCoordinator.doClearing`: the
549
+ * `applyInterests` call before steps 1–5), then accepts deposits and
550
+ * withdrawals. So an update is in force for epoch E's interest when it is
551
+ * tagged `≤ E` (the subgraph tags every update written during clearing E, or
552
+ * after it, `E + 1` or later), OR when it was written before clearing E began
553
+ * (`timestamp < clearingStart`). The second test is the one that matters: a
554
+ * withdrawal the pool manager accepts BETWEEN clearings
555
+ * (`forceAcceptWithdrawalRequest`) is tagged `E + 1` by the subgraph, as if a
556
+ * clearing had accepted it, yet the shares were gone before clearing E.
557
+ *
558
+ * Without a clearing start (none recorded) it is `sharesAsOf` — the tag alone.
559
+ */
560
+ export function sharesAtInterest<T extends TimedShareSnapshot>(
561
+ updates: readonly T[],
562
+ epoch: number,
563
+ clearingStart: number | undefined,
564
+ ): T | undefined {
565
+ let found: T | undefined;
566
+ for (const update of updates) {
567
+ const inForce =
568
+ Number(update.epochId) <= epoch ||
569
+ (clearingStart !== undefined &&
570
+ Number(update.timestamp) < clearingStart);
571
+ if (!inForce) continue;
572
+ if (!found || update.shareUpdatesIndex > found.shareUpdatesIndex) {
573
+ found = update;
574
+ }
575
+ }
576
+ return found;
577
+ }
578
+
579
+ /**
580
+ * The tranche's total shares when the clearing of `epoch` applied its
581
+ * interest.
582
+ *
583
+ * The subgraph writes a tranche snapshot when a clearing ENDS (tagged with
584
+ * the next epoch) and at each immediate withdrawal, but none when the manager
585
+ * accepts a withdrawal between clearings — so the newest snapshot can predate
586
+ * shares that had already left. Instead of guessing the order of events in a
587
+ * block, the total is rebuilt from the one snapshot whose place is certain:
588
+ * the FIRST one tagged `epoch`, written when clearing `epoch − 1` ended (the
589
+ * tag itself starts there). Every share exit between that and clearing
590
+ * `epoch` is tagged `epoch` (the pool's clearing epoch at the time), and none
591
+ * can happen during a clearing (`notPendingClearing`,
592
+ * `verifyClearingNotPending`), while deposits and top-ups only happen inside
593
+ * one. So: that snapshot less every exit tagged `epoch` before the clearing's
594
+ * start (the clearing's own acceptances come at or after it).
595
+ *
596
+ * Without a clearing start, or without a snapshot tagged `epoch` (a tranche's
597
+ * first clearing), it is the snapshot in force by tag alone.
598
+ */
599
+ export function trancheSharesAtInterest(
600
+ snapshots: readonly TimedShareSnapshot[],
601
+ exits: readonly Pick<
602
+ ShareExitRow,
603
+ 'epochId' | 'createdOn' | 'sharesAmount'
604
+ >[],
605
+ epoch: number,
606
+ clearingStart: number | undefined,
607
+ ): number {
608
+ let base: TimedShareSnapshot | undefined;
609
+ if (clearingStart !== undefined) {
610
+ for (const snapshot of snapshots) {
611
+ if (Number(snapshot.epochId) !== epoch) continue;
612
+ if (!base || snapshot.shareUpdatesIndex < base.shareUpdatesIndex) {
613
+ base = snapshot;
614
+ }
615
+ }
616
+ }
617
+ if (clearingStart === undefined || !base) {
618
+ return parseFloat(sharesAsOf(snapshots, epoch)?.shares ?? '0');
619
+ }
620
+
621
+ let shares = parseFloat(base.shares);
622
+ for (const exit of exits) {
623
+ if (
624
+ Number(exit.epochId) === epoch &&
625
+ Number(exit.createdOn) < clearingStart
626
+ ) {
627
+ shares -= parseFloat(exit.sharesAmount ?? '0');
628
+ }
629
+ }
630
+ return shares;
631
+ }
632
+
633
+ /** A fixed-term lock inside one lender's tranche position. */
634
+ export interface PositionLock {
635
+ epochLockStart: string;
636
+ epochLockEnd: string;
637
+ initialTrancheShares: string;
638
+ /** The lock's share snapshots (paired by tag: its tags are right). */
639
+ updates: readonly Pick<
640
+ ShareUpdateRow,
641
+ 'epochId' | 'shareUpdatesIndex' | 'shares'
642
+ >[];
643
+ }
644
+
645
+ /** Everything one lender's tranche position needs to price one epoch. */
646
+ export interface PositionSeries {
647
+ trancheBalanceNow: number;
648
+ trancheSharesNow: number;
649
+ userUpdates: readonly TimedShareSnapshot[];
650
+ trancheUpdates: readonly TimedShareSnapshot[];
651
+ exits: readonly Pick<
652
+ ShareExitRow,
653
+ 'epochId' | 'createdOn' | 'sharesAmount'
654
+ >[];
655
+ locks: readonly PositionLock[];
656
+ }
657
+
658
+ /**
659
+ * THE per-position, per-epoch selection both `getYieldHistory` and
660
+ * `getPortfolioLendingData` run: the shares in force when clearing `epoch`
661
+ * applied its interest (`sharesAtInterest`, `trancheSharesAtInterest`), each
662
+ * lock's snapshots as of `epoch` and `epoch − 1`, then `epochTrancheYield`.
663
+ */
664
+ export function positionEpochYield(
665
+ series: PositionSeries,
666
+ epoch: number,
667
+ epochInterest: number,
668
+ clearingStart: number | undefined,
669
+ ): { yield: EpochTrancheYield; userShares: number } {
670
+ const userSnapshot = sharesAtInterest(
671
+ series.userUpdates,
672
+ epoch,
673
+ clearingStart,
674
+ );
675
+ const userShares = parseFloat(userSnapshot?.shares ?? '0');
676
+
677
+ const locks: EpochLockShares[] = series.locks.map((lock) => ({
678
+ active:
679
+ parseFloat(lock.epochLockStart) < epoch &&
680
+ parseFloat(lock.epochLockEnd) >= epoch &&
681
+ userSnapshot !== undefined,
682
+ // No snapshot yet = the lock's opening shares: a lock made after
683
+ // clearing E starts at E but its first update is tagged E + 1, and
684
+ // reading that gap as 0 would book its principal as a top-up.
685
+ sharesAtEpoch: parseFloat(
686
+ sharesAsOf(lock.updates, epoch)?.shares ??
687
+ lock.initialTrancheShares,
688
+ ),
689
+ sharesBeforeEpoch: parseFloat(
690
+ sharesAsOf(lock.updates, epoch - 1)?.shares ??
691
+ lock.initialTrancheShares,
692
+ ),
693
+ }));
694
+
695
+ return {
696
+ yield: epochTrancheYield({
697
+ epochInterest,
698
+ userShares,
699
+ trancheShares: trancheSharesAtInterest(
700
+ series.trancheUpdates,
701
+ series.exits,
702
+ epoch,
703
+ clearingStart,
704
+ ),
705
+ trancheBalanceNow: series.trancheBalanceNow,
706
+ trancheSharesNow: series.trancheSharesNow,
707
+ locks,
708
+ }),
709
+ userShares,
710
+ };
711
+ }
712
+
713
+ /** One lender's tranche position, as the positions and portfolio queries read it. */
714
+ export interface PositionRef {
715
+ id: string;
716
+ tranche: { id: string; balance: string; shares: string };
717
+ userLendingPoolTrancheFixedTermDepositLocks: {
718
+ id?: string;
719
+ epochLockStart: string;
720
+ epochLockEnd: string;
721
+ initialTrancheShares: string;
722
+ }[];
723
+ }
724
+
725
+ /** A position's `PositionSeries` out of the fetched history series. */
726
+ export function positionSeriesFrom(
727
+ rows: Omit<YieldHistoryRead, 'positions'>,
728
+ position: PositionRef,
729
+ ): PositionSeries {
730
+ const trancheId = position.tranche.id;
731
+ return {
732
+ trancheBalanceNow: parseFloat(position.tranche.balance),
733
+ trancheSharesNow: parseFloat(position.tranche.shares),
734
+ userUpdates: rows.userShares.filter(
735
+ (row) => row.lendingPoolTrancheUserDetails.id === position.id,
736
+ ),
737
+ trancheUpdates: rows.trancheShares.filter(
738
+ (row) => row.lendingPoolTranche.id === trancheId,
739
+ ),
740
+ exits: rows.withdrawals.filter((row) => row.tranche.id === trancheId),
741
+ locks: position.userLendingPoolTrancheFixedTermDepositLocks.map(
742
+ (lock) => ({
743
+ ...lock,
744
+ updates: rows.lockShares.filter(
745
+ (row) =>
746
+ row.userLendingPoolTrancheFixedTermDepositLock.id ===
747
+ lock.id,
748
+ ),
749
+ }),
750
+ ),
751
+ };
752
+ }
753
+
754
+ /** `pool → epoch → clearing start`, from the clearing rows. */
755
+ export function clearingStarts(
756
+ clearings: readonly ClearingRow[],
757
+ ): Map<string, Map<number, number>> {
758
+ const starts = new Map<string, Map<number, number>>();
759
+ for (const clearing of clearings) {
760
+ const start = Number(clearing.startTimestamp);
761
+ // A clearing not yet started carries 0: no start to pair by.
762
+ if (!(start > 0)) continue;
763
+ const pool = clearing.lendingPool.id.toLowerCase();
764
+ const byEpoch = starts.get(pool) ?? new Map<number, number>();
765
+ byEpoch.set(Number(clearing.epochId), start);
766
+ starts.set(pool, byEpoch);
767
+ }
768
+ return starts;
769
+ }
770
+
411
771
  function groupBy<T>(
412
772
  rows: readonly T[],
413
773
  key: (row: T) => string,
@@ -426,7 +786,8 @@ function groupBy<T>(
426
786
  * Pure: the fetched series → one entry per pool, tranche and settled epoch in
427
787
  * which the lender held shares (or a lock earned a top-up). Each entry runs the
428
788
  * same `epochTrancheYield` as the portfolio's last-epoch figure, with the same
429
- * share selection, so the newest entry equals that figure.
789
+ * share selection (`sharesAtInterest`, `trancheSharesAtInterest`), so the
790
+ * newest entry equals that figure.
430
791
  *
431
792
  * Sorted by `epochId`, then `poolId`, then `trancheId`.
432
793
  */
@@ -454,14 +815,22 @@ export function buildYieldHistory(
454
815
  read.lockShares,
455
816
  (row) => row.userLendingPoolTrancheFixedTermDepositLock.id,
456
817
  );
818
+ const withdrawalsByTranche = groupBy(
819
+ read.withdrawals,
820
+ (row) => row.tranche.id,
821
+ );
822
+ const starts = clearingStarts(read.clearings);
457
823
 
458
824
  const entries: YieldHistoryEntry[] = [];
459
825
 
460
826
  for (const pool of user.lendingPoolUserDetails) {
827
+ const poolStarts = starts.get(pool.lendingPool.id.toLowerCase());
461
828
  for (const position of pool.lendingPoolTrancheUserDetails) {
462
829
  const trancheId = position.tranche.id;
463
830
  const trancheUpdates = trancheSharesByTranche.get(trancheId) ?? [];
464
831
  const userUpdates = userSharesByPosition.get(position.id) ?? [];
832
+ const trancheWithdrawals =
833
+ withdrawalsByTranche.get(trancheId) ?? [];
465
834
  const trancheBalanceNow = parseFloat(position.tranche.balance);
466
835
  const trancheSharesNow = parseFloat(position.tranche.shares);
467
836
 
@@ -470,47 +839,27 @@ export function buildYieldHistory(
470
839
  if (epoch < window.fromEpoch || epoch > window.toEpoch) {
471
840
  continue;
472
841
  }
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
- });
842
+ const clearingStart = poolStarts?.get(epoch);
843
+
844
+ const { yield: epochYield, userShares } = positionEpochYield(
845
+ {
846
+ trancheBalanceNow,
847
+ trancheSharesNow,
848
+ userUpdates,
849
+ trancheUpdates,
850
+ exits: trancheWithdrawals,
851
+ locks: position.userLendingPoolTrancheFixedTermDepositLocks.map(
852
+ (lock) => ({
853
+ ...lock,
854
+ updates: lockSharesByLock.get(lock.id) ?? [],
855
+ }),
856
+ ),
857
+ },
858
+ epoch,
859
+ parseFloat(record.epochInterestAmount),
860
+ clearingStart,
861
+ );
862
+ const total = epochYield.total;
514
863
 
515
864
  // Nothing held and nothing topped up: not part of this
516
865
  // lender's history (a new lender's history is empty).