@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
@@ -1,6 +1,11 @@
1
1
  import { Provider } from '@ethersproject/providers';
2
2
  import { BigNumber, Signer } from 'ethers';
3
- import { formatEther, parseEther, parseUnits } from 'ethers/lib/utils';
3
+ import {
4
+ formatEther,
5
+ formatUnits,
6
+ parseEther,
7
+ parseUnits,
8
+ } from 'ethers/lib/utils';
4
9
  import { GraphQLClient } from 'graphql-request';
5
10
 
6
11
  import {
@@ -11,7 +16,7 @@ import {
11
16
  IUserLoyaltyRewardsAbi__factory,
12
17
  } from '../../contracts';
13
18
  import {
14
- epochTrancheYield,
19
+ EpochTrancheYield,
15
20
  YieldHistoryEntry,
16
21
  } from '../../domain/yield-history';
17
22
  import { SdkConfig } from '../../sdk-config';
@@ -35,10 +40,12 @@ import {
35
40
  LockingSummarySubgraphResult,
36
41
  SystemVariables,
37
42
  } from '../Locking/types';
43
+ import { isBlockUnavailableError } from '../shared';
38
44
  import { UserLending } from '../UserLending/user-lending';
39
45
 
40
46
  import { lendingPortfolioQuery } from './queries';
41
47
  import {
48
+ LedgerMismatch,
42
49
  LendingPortfolioQueryResult,
43
50
  PortfolioLendingPool,
44
51
  PortfolioRewards,
@@ -49,8 +56,11 @@ import {
49
56
  } from './types';
50
57
  import {
51
58
  buildYieldHistory,
59
+ clearingStarts,
52
60
  fetchYieldHistorySeries,
53
61
  isEmptyHistoryError,
62
+ positionEpochYield,
63
+ positionSeriesFrom,
54
64
  resolveYieldHistoryWindow,
55
65
  validateYieldHistoryOptions,
56
66
  YIELD_HISTORY_PAGE_SIZE,
@@ -61,6 +71,74 @@ import {
61
71
  YieldHistoryPositionsResult,
62
72
  } from './yield-history';
63
73
 
74
+ /**
75
+ * How many epochs back the portfolio read pairs a tranche's newest interest
76
+ * with the shares in force at its clearing (`lendingPortfolioQuery`): the last
77
+ * six settled epochs, so a pool whose clearings run late or lag a few weeks
78
+ * behind the others (Base has one) is paired too. A pool whose newest
79
+ * interest is older (pools clear independently; some only when the
80
+ * originator repays) is priced from a history read of its own, at that
81
+ * epoch. The extra rows are a few per lender and tranche.
82
+ */
83
+ const RECENT_PAIRING_EPOCHS = 6;
84
+
85
+ /**
86
+ * The ledger (subgraph) and the chain agree on a balance AT THE SAME BLOCK
87
+ * within a cent, or a millionth of it — the rounding of two share-price
88
+ * conversions.
89
+ */
90
+ const BALANCE_RECONCILE_TOLERANCE = 0.01;
91
+ const BALANCE_RECONCILE_RELATIVE = 1e-6;
92
+
93
+ function ledgerAgrees(ledger: number, chain: number): boolean {
94
+ return (
95
+ Math.abs(ledger - chain) <=
96
+ Math.max(
97
+ BALANCE_RECONCILE_TOLERANCE,
98
+ Math.abs(chain) * BALANCE_RECONCILE_RELATIVE,
99
+ )
100
+ );
101
+ }
102
+
103
+ /**
104
+ * A list read with `first: $first` that comes back FULL may have been cut:
105
+ * refuse it rather than price from a truncated read.
106
+ */
107
+ function refuseTruncated(data: LendingPortfolioQueryResult): void {
108
+ const full = (rows: readonly unknown[] | undefined): boolean =>
109
+ (rows?.length ?? 0) >= YIELD_HISTORY_PAGE_SIZE;
110
+ const lists: [string, boolean][] = [
111
+ ['lendingPoolClearings', full(data.lendingPoolClearings)],
112
+ ['recentWithdrawalsAccepted', full(data.recentWithdrawalsAccepted)],
113
+ ];
114
+ for (const pool of data.user?.lendingPoolUserDetails ?? []) {
115
+ for (const position of pool.lendingPoolTrancheUserDetails) {
116
+ lists.push(
117
+ [
118
+ 'recentUserShareUpdates',
119
+ full(position.recentUserShareUpdates),
120
+ ],
121
+ [
122
+ 'recentTrancheShareUpdates',
123
+ full(position.tranche.recentTrancheShareUpdates),
124
+ ],
125
+ ...position.userLendingPoolTrancheFixedTermDepositLocks.map(
126
+ (lock): [string, boolean] => [
127
+ 'lockSharesRecent',
128
+ full(lock.lockSharesRecent),
129
+ ],
130
+ ),
131
+ );
132
+ }
133
+ }
134
+ const cut = lists.find(([, isFull]) => isFull);
135
+ if (cut) {
136
+ throw new Error(
137
+ `getPortfolioLendingData: ${cut[0]} reached ${String(YIELD_HISTORY_PAGE_SIZE)} rows; refusing a possibly truncated read`,
138
+ );
139
+ }
140
+ }
141
+
64
142
  export class Portfolio {
65
143
  private _lockingService: KSULocking;
66
144
  private _dataService: DataService;
@@ -451,30 +529,94 @@ export class Portfolio {
451
529
  const previousEpoch = BigNumber.from(currentEpoch).sub(
452
530
  BigNumber.from(1),
453
531
  );
532
+ const recentEpoch = Math.max(
533
+ 0,
534
+ previousEpoch.toNumber() - (RECENT_PAIRING_EPOCHS - 1),
535
+ );
454
536
 
455
- const [lastEpochData, portfolioUserTrancheBalances, userPoolBalances] =
456
- await Promise.all([
457
- this._graph.request<LendingPortfolioQueryResult>(
458
- lendingPortfolioQuery,
459
- {
460
- userAddress: userAddress.toLowerCase(),
461
- epochId: parseFloat(currentEpoch),
462
- lastEpochId: previousEpoch.toNumber(),
463
- unusedPools: this._kasuConfig.UNUSED_LENDING_POOL_IDS,
464
- first: YIELD_HISTORY_PAGE_SIZE,
465
- },
466
- ),
467
- this._userLendingService.getPortfolioUserTrancheBalances(
537
+ const unused = new Set(
538
+ this._kasuConfig.UNUSED_LENDING_POOL_IDS.map((id) =>
539
+ id.toLowerCase(),
540
+ ),
541
+ );
542
+ const listedPools = poolOverviews.filter(
543
+ ({ id }) => !unused.has(id.toLowerCase()),
544
+ );
545
+
546
+ const lastEpochData =
547
+ await this._graph.request<LendingPortfolioQueryResult>(
548
+ lendingPortfolioQuery,
549
+ {
550
+ userAddress: userAddress.toLowerCase(),
551
+ epochId: parseFloat(currentEpoch),
552
+ lastEpochId: previousEpoch.toNumber(),
553
+ recentEpoch,
554
+ lockRecentEpoch: Math.max(0, recentEpoch - 1),
555
+ unusedPools: this._kasuConfig.UNUSED_LENDING_POOL_IDS,
556
+ poolIds: listedPools.map(({ id }) => id.toLowerCase()),
557
+ trancheIds: listedPools.flatMap(({ tranches }) =>
558
+ tranches.map(({ id }) => id.toLowerCase()),
559
+ ),
560
+ first: YIELD_HISTORY_PAGE_SIZE,
561
+ },
562
+ );
563
+
564
+ if (!lastEpochData.user) return [];
565
+ refuseTruncated(lastEpochData);
566
+
567
+ // The pools the lender has a ledger in AND that are listed: the only
568
+ // ones read on chain, and the only ones reported.
569
+ const userPools = listedPools.flatMap((overview) => {
570
+ const details = lastEpochData.user?.lendingPoolUserDetails.find(
571
+ ({ lendingPool }) =>
572
+ lendingPool.id.toLowerCase() === overview.id.toLowerCase(),
573
+ );
574
+ return details ? [{ overview, details }] : [];
575
+ });
576
+
577
+ const recentStarts = clearingStarts(lastEpochData.lendingPoolClearings);
578
+ const recentExits = new Map<
579
+ string,
580
+ LendingPortfolioQueryResult['recentWithdrawalsAccepted']
581
+ >();
582
+ for (const exit of lastEpochData.recentWithdrawalsAccepted) {
583
+ const list = recentExits.get(exit.tranche.id) ?? [];
584
+ list.push(exit);
585
+ recentExits.set(exit.tranche.id, list);
586
+ }
587
+
588
+ const [chain, staleYields] = await Promise.all([
589
+ this.readChainAt(lastEpochData._meta?.block.number, (blockTag) =>
590
+ this._userLendingService.getUserPoolBalancesAt(
468
591
  userAddress,
592
+ userPools.map(({ overview }) => overview.id),
593
+ provider,
594
+ blockTag,
595
+ ),
596
+ ),
597
+ this.staleLastEpochYields(
598
+ userPools.flatMap(({ overview, details }) =>
599
+ details.lendingPoolTrancheUserDetails.map((position) => ({
600
+ poolId: overview.id,
601
+ position,
602
+ })),
469
603
  ),
470
- this._userLendingService.getUserPoolBalance(
604
+ recentEpoch,
605
+ ),
606
+ ]);
607
+
608
+ const reconciled = await Promise.all(
609
+ userPools.map(({ overview, details }, index) =>
610
+ this.reconcilePool(
471
611
  userAddress,
472
- poolOverviews.map(({ id }) => id),
612
+ overview,
613
+ details,
614
+ chain.values[index] ?? BigNumber.from(0),
615
+ chain.blockTag,
473
616
  provider,
474
617
  ),
475
- ]);
476
-
477
- if (!lastEpochData.user) return [];
618
+ ),
619
+ );
478
620
 
479
621
  const userRequestsMap = new Map<
480
622
  string,
@@ -526,16 +668,12 @@ export class Portfolio {
526
668
  }
527
669
  }
528
670
 
529
- let totalInvestments = BigNumber.from(0);
671
+ for (const [
672
+ poolIndex,
673
+ { overview: poolOverview, details },
674
+ ] of userPools.entries()) {
675
+ const pool = reconciled[poolIndex];
530
676
 
531
- for (const poolOverview of poolOverviews) {
532
- const userPoolBalance = userPoolBalances.find(
533
- ({ poolId }) => poolId === poolOverview.id,
534
- );
535
- if (!userPoolBalance) {
536
- continue;
537
- }
538
- totalInvestments = userPoolBalance.balance.add(totalInvestments);
539
677
  const tranches: (Omit<PortfolioTranche, 'investedAmount'> & {
540
678
  investedAmount: {
541
679
  balance: string;
@@ -545,19 +683,13 @@ export class Portfolio {
545
683
 
546
684
  let poolYieldEarnedLastEpoch = 0;
547
685
 
548
- const lendingPoolUserDetails =
549
- lastEpochData.user.lendingPoolUserDetails.find(
550
- ({ lendingPool }) => lendingPool.id === poolOverview.id,
551
- );
552
-
553
- if (!lendingPoolUserDetails) continue;
554
-
555
- const portfolioUserTrancheBalance =
556
- portfolioUserTrancheBalances.get(poolOverview.id);
686
+ // A ledger crediting shares the wallet does not hold earns
687
+ // nothing we can vouch for: its last-cycle figures are withheld.
688
+ const withheld = pool.mismatch?.kind === 'ledger-exceeds-chain';
557
689
 
558
690
  for (const tranche of poolOverview.tranches) {
559
691
  const lastEpochUserTrancheDetails =
560
- lendingPoolUserDetails.lendingPoolTrancheUserDetails.find(
692
+ details.lendingPoolTrancheUserDetails.find(
561
693
  (lendingPool) => lendingPool.tranche.id === tranche.id,
562
694
  );
563
695
 
@@ -581,86 +713,83 @@ export class Portfolio {
581
713
  });
582
714
  continue;
583
715
  }
584
- const lastEpoch = previousEpoch.toNumber();
585
716
 
586
- const userHasLastEpochShares = Boolean(
587
- lastEpochUserTrancheDetails
588
- .lendingPoolTrancheUserEpochSharesUpdates?.length,
589
- );
717
+ // The tranche's newest interest (epoch E), priced with the
718
+ // shares in force when clearing E applied it — the one
719
+ // selection `getYieldHistory` makes (`positionEpochYield`).
720
+ // Inside the recent window the candidates below are complete.
721
+ // An older E (a pool that clears rarely — some only when the
722
+ // originator repays) is priced from a history read of its own,
723
+ // and is 0 for a lender who has left the tranche.
724
+ const newestInterest =
725
+ lastEpochUserTrancheDetails.tranche
726
+ .lendingPoolTrancheEpochInterest?.[0];
727
+ const interestEpoch =
728
+ newestInterest?.epochId === undefined
729
+ ? undefined
730
+ : Number(newestInterest.epochId);
590
731
 
591
732
  const fixedTermDeposits =
592
733
  lastEpochUserTrancheDetails.userLendingPoolTrancheFixedTermDepositLocks;
593
734
 
594
- // The one per-epoch formula `getYieldHistory` runs too, with
595
- // the same share selection — so the last bar of a history
596
- // equals these figures.
597
- const lastEpochYield = epochTrancheYield({
598
- epochInterest: parseFloat(
599
- lastEpochUserTrancheDetails.tranche
600
- .lendingPoolTrancheEpochInterest?.[0]
601
- ?.epochInterestAmount ?? '0',
602
- ),
603
- userShares: parseFloat(
604
- lastEpochUserTrancheDetails
605
- .lendingPoolTrancheUserEpochSharesUpdates?.[0]
606
- ?.shares ?? '0',
607
- ),
608
- trancheShares: parseFloat(
609
- lastEpochUserTrancheDetails.tranche
610
- .lendingPoolTrancheShareUpdates?.[0]?.shares ?? '0',
611
- ),
612
- trancheBalanceNow: parseFloat(
613
- lastEpochUserTrancheDetails.tranche.balance,
614
- ),
615
- trancheSharesNow: parseFloat(
616
- lastEpochUserTrancheDetails.tranche.shares,
617
- ),
618
- locks: fixedTermDeposits.map((fixedTermDeposit) => ({
619
- active:
620
- parseFloat(fixedTermDeposit.epochLockStart) <
621
- lastEpoch &&
622
- parseFloat(fixedTermDeposit.epochLockEnd) >=
623
- lastEpoch &&
624
- userHasLastEpochShares,
625
- // No snapshot = the lock's opening shares, exactly
626
- // as `getYieldHistory` reads it: a lock made after
627
- // clearing E starts at E with its first update
628
- // tagged E + 1. Never the CURRENT shares, which
629
- // would carry a top-up from a clearing that ran
630
- // after the last settled epoch.
631
- sharesAtEpoch: parseFloat(
632
- fixedTermDeposit.lockSharesAtLastEpoch?.[0]
633
- ?.shares ??
634
- fixedTermDeposit.initialTrancheShares,
635
- ),
636
- sharesBeforeEpoch: parseFloat(
637
- fixedTermDeposit
638
- .userLendingPoolTrancheFixedTermDepositLockShareUpdate?.[0]
639
- ?.shares ??
640
- fixedTermDeposit.initialTrancheShares,
641
- ),
642
- })),
643
- });
735
+ const noYield: EpochTrancheYield = {
736
+ total: 0,
737
+ variable: 0,
738
+ locks: fixedTermDeposits.map(() => 0),
739
+ };
740
+ let lastEpochYield = noYield;
741
+ if (
742
+ interestEpoch !== undefined &&
743
+ interestEpoch < recentEpoch &&
744
+ !withheld
745
+ ) {
746
+ lastEpochYield =
747
+ staleYields.get(lastEpochUserTrancheDetails.id) ??
748
+ noYield;
749
+ } else if (interestEpoch !== undefined && !withheld) {
750
+ lastEpochYield = positionEpochYield(
751
+ {
752
+ trancheBalanceNow: parseFloat(
753
+ lastEpochUserTrancheDetails.tranche.balance,
754
+ ),
755
+ trancheSharesNow: parseFloat(
756
+ lastEpochUserTrancheDetails.tranche.shares,
757
+ ),
758
+ userUpdates: [
759
+ ...(lastEpochUserTrancheDetails.lendingPoolTrancheUserEpochSharesUpdates ??
760
+ []),
761
+ ...(lastEpochUserTrancheDetails.recentUserShareUpdates ??
762
+ []),
763
+ ],
764
+ trancheUpdates: [
765
+ ...(lastEpochUserTrancheDetails.tranche
766
+ .lendingPoolTrancheShareUpdates ?? []),
767
+ ...(lastEpochUserTrancheDetails.tranche
768
+ .recentTrancheShareUpdates ?? []),
769
+ ],
770
+ exits: recentExits.get(tranche.id) ?? [],
771
+ locks: fixedTermDeposits.map((lock) => ({
772
+ ...lock,
773
+ updates: [
774
+ ...(lock.lockSharesBase ?? []),
775
+ ...(lock.lockSharesRecent ?? []),
776
+ ],
777
+ })),
778
+ },
779
+ interestEpoch,
780
+ parseFloat(newestInterest?.epochInterestAmount ?? '0'),
781
+ recentStarts
782
+ .get(poolOverview.id.toLowerCase())
783
+ ?.get(interestEpoch),
784
+ ).yield;
785
+ }
644
786
 
787
+ const ledger = pool.tranches.get(tranche.id);
645
788
  const userTrancheBalance = {
646
- balance: '0',
647
- yieldEarned: 0,
789
+ balance: ledger?.balance ?? '0',
790
+ yieldEarned: ledger?.yieldEarned ?? 0,
648
791
  };
649
792
 
650
- if (portfolioUserTrancheBalance) {
651
- const portfolioUserTranche =
652
- portfolioUserTrancheBalance.find(
653
- ({ trancheId }) => trancheId === tranche.id,
654
- );
655
-
656
- if (portfolioUserTranche) {
657
- userTrancheBalance.balance =
658
- portfolioUserTranche.userBalance;
659
- userTrancheBalance.yieldEarned =
660
- portfolioUserTranche.yieldEarned;
661
- }
662
- }
663
-
664
793
  let totalLifetimeFtdYield = 0;
665
794
 
666
795
  const fixedLoans: PortfolioTranche['fixedLoans'] =
@@ -754,9 +883,12 @@ export class Portfolio {
754
883
  totalYieldEarningsLastEpoch: this.precisionToString(
755
884
  poolYieldEarnedLastEpoch,
756
885
  ),
757
- totalInvestedAmount: userPoolBalance.balance.toString(),
758
- totalYieldEarningsLifetime:
759
- userPoolBalance.yieldEarned.toString(),
886
+ totalInvestedAmount: pool.chainBalance.toString(),
887
+ totalYieldEarningsLifetime: pool.lifetime.toString(),
888
+ ...(pool.mismatch ? { ledgerMismatch: pool.mismatch } : {}),
889
+ ...(chain.blockTag === undefined
890
+ ? { chainPinned: false as const }
891
+ : {}),
760
892
  tranches,
761
893
  });
762
894
  }
@@ -782,13 +914,22 @@ export class Portfolio {
782
914
  tranche.yieldEarnings.lifetime,
783
915
  ).toFixed(6);
784
916
 
917
+ // Never negative: a variable balance below the locked
918
+ // amount is a ledger the chain does not back (the pool
919
+ // carries `ledgerMismatch`), not money owed — flagged.
920
+ const variableBalance = parseEther(
921
+ trancheBalance.balance,
922
+ ).sub(lockedAmount);
923
+ const floored = variableBalance.isNegative();
924
+
785
925
  return {
786
926
  ...tranche,
787
927
  investedAmount: formatEther(
788
- parseEther(trancheBalance.balance).sub(
789
- lockedAmount,
790
- ),
928
+ floored ? BigNumber.from(0) : variableBalance,
791
929
  ),
930
+ ...(floored
931
+ ? { investedAmountFloored: true as const }
932
+ : {}),
792
933
  yieldEarnings: {
793
934
  ...tranche.yieldEarnings,
794
935
  lifetime: this.precisionToString(
@@ -826,7 +967,12 @@ export class Portfolio {
826
967
  * Cost: three parallel `SystemVariables` reads and the positions query in
827
968
  * one round, then one subgraph request per page of the longest series
828
969
  * (1000 rows a page) — normally two subgraph round trips in all, never one
829
- * per epoch.
970
+ * per epoch — alongside one `LendingPool.userBalance` call per pool at
971
+ * the subgraph's block. A pool whose ledger credits more than the wallet
972
+ * holds at that block (shares moved away outside a withdrawal) is left
973
+ * out; if the node says it cannot serve that block, `latest` is read
974
+ * and nothing is. Any other failure of those reads (a rate limit, a
975
+ * network error) THROWS: the history needs a working RPC.
830
976
  *
831
977
  * On a LITE deployment, a subgraph that lacks the entities (a validation
832
978
  * error naming them) yields `[]`; on a full deployment that error throws,
@@ -867,7 +1013,9 @@ export class Portfolio {
867
1013
  const userTranches: string[] = [];
868
1014
  const tranches = new Set<string>();
869
1015
  const locks: string[] = [];
1016
+ const pools: string[] = [];
870
1017
  for (const pool of positions.user.lendingPoolUserDetails) {
1018
+ pools.push(pool.lendingPool.id);
871
1019
  for (const position of pool.lendingPoolTrancheUserDetails) {
872
1020
  userTranches.push(position.id);
873
1021
  tranches.add(position.tranche.id);
@@ -878,26 +1026,311 @@ export class Portfolio {
878
1026
  }
879
1027
  if (userTranches.length === 0) return [];
880
1028
 
881
- const { rows } = await fetchYieldHistorySeries(
882
- (variables) =>
883
- this._graph.request<YieldHistoryPageResult>(
884
- yieldHistoryPageQuery,
885
- variables,
1029
+ const [{ rows }, chain] = await Promise.all([
1030
+ fetchYieldHistorySeries(
1031
+ (variables) =>
1032
+ this._graph.request<YieldHistoryPageResult>(
1033
+ yieldHistoryPageQuery,
1034
+ variables,
1035
+ ),
1036
+ { pools, tranches: [...tranches], userTranches, locks },
1037
+ window,
1038
+ ),
1039
+ this.readChainAt(positions._meta?.block.number, (blockTag) =>
1040
+ this._userLendingService.getUserPoolBalancesAt(
1041
+ userAddress,
1042
+ pools,
1043
+ undefined,
1044
+ blockTag,
886
1045
  ),
887
- { tranches: [...tranches], userTranches, locks },
888
- window,
889
- );
1046
+ ),
1047
+ ]);
1048
+
1049
+ // A pool whose ledger credits shares the wallet does not hold at
1050
+ // the same block (shares moved away outside a withdrawal) has no
1051
+ // history we can vouch for: it is left out. Unpinned, nothing is.
1052
+ const decimals = this._kasuConfig.stableAssetDecimals;
1053
+ const withheld = new Set<string>();
1054
+ if (chain.blockTag !== undefined) {
1055
+ positions.user.lendingPoolUserDetails.forEach((pool, index) => {
1056
+ const ledger = pool.lendingPoolTrancheUserDetails.reduce(
1057
+ (sum, position) =>
1058
+ sum +
1059
+ parseFloat(
1060
+ this._userLendingService.convertSharesToAssets(
1061
+ position.shares ?? '0',
1062
+ position.tranche.balance,
1063
+ position.tranche.shares,
1064
+ ),
1065
+ ),
1066
+ 0,
1067
+ );
1068
+ const held = parseFloat(
1069
+ formatUnits(
1070
+ chain.values[index] ?? BigNumber.from(0),
1071
+ decimals,
1072
+ ),
1073
+ );
1074
+ if (ledger > held && !ledgerAgrees(ledger, held)) {
1075
+ withheld.add(pool.lendingPool.id.toLowerCase());
1076
+ }
1077
+ });
1078
+ }
890
1079
 
891
1080
  return buildYieldHistory({ positions, ...rows }, window, {
892
1081
  epochZeroStart: epochZeroStart.toNumber(),
893
1082
  epochDuration: epochDuration.toNumber(),
894
- });
1083
+ }).filter((entry) => !withheld.has(entry.poolId.toLowerCase()));
895
1084
  } catch (error) {
896
1085
  if (isEmptyHistoryError(error, this._isLiteDeployment)) return [];
897
1086
  throw error;
898
1087
  }
899
1088
  }
900
1089
 
1090
+ /**
1091
+ * Read the chain at the subgraph's block, so the chain and the ledger
1092
+ * describe the same state: after every clearing the subgraph runs a few
1093
+ * blocks behind, and a `latest` read would then disagree with an
1094
+ * honest ledger (interest moved every balance, deposits were accepted)
1095
+ * and book that difference as a ledger fault.
1096
+ *
1097
+ * Only when the node says it cannot serve that block (pruned, or behind
1098
+ * the subgraph: `isBlockUnavailableError`) does it fall back to `latest`,
1099
+ * and it says so (`blockTag: undefined`): unpinned figures are never
1100
+ * compared with the ledger, and `getPortfolioLendingData` marks the pools
1101
+ * `chainPinned: false`. Any other failure — a rate limit, a timeout, a
1102
+ * network error — is thrown, never turned into an unpinned read.
1103
+ */
1104
+ private async readChainAt<T>(
1105
+ block: number | undefined,
1106
+ read: (blockTag: number | undefined) => Promise<T>,
1107
+ ): Promise<{ values: T; blockTag: number | undefined }> {
1108
+ if (block !== undefined) {
1109
+ try {
1110
+ return { values: await read(block), blockTag: block };
1111
+ } catch (error) {
1112
+ if (!isBlockUnavailableError(error)) throw error;
1113
+ }
1114
+ }
1115
+ return { values: await read(undefined), blockTag: undefined };
1116
+ }
1117
+
1118
+ /**
1119
+ * One pool's balances: the ledger's (the subgraph counts a lender's
1120
+ * shares as accepted deposits − accepted withdrawals ± top-ups) checked
1121
+ * against the chain's `LendingPool.userBalance` at the SAME block.
1122
+ *
1123
+ * The ledger does not follow shares that move between wallets any other
1124
+ * way (an administrative move of a position is the case seen on chain):
1125
+ * the receiving wallet goes negative as it withdraws, the sending one
1126
+ * keeps shares it no longer holds. Where the two disagree at that block —
1127
+ * or any ledger balance is negative — the tranche balances are read from
1128
+ * the chain at that block (`userActiveAssets`, one call per tranche), the
1129
+ * tranche's ledger `yieldEarned` moves by the same difference, and the
1130
+ * pool carries `ledgerMismatch`. Unpinned (the node could not serve the
1131
+ * block), only a negative ledger balance is corrected. A pool that agrees
1132
+ * — every ordinary lender — costs nothing extra.
1133
+ *
1134
+ * Lifetime figures stay ledger figures: they cannot tell moved principal
1135
+ * from interest.
1136
+ */
1137
+ private async reconcilePool(
1138
+ userAddress: string,
1139
+ overview: PoolOverview,
1140
+ details: NonNullable<
1141
+ LendingPortfolioQueryResult['user']
1142
+ >['lendingPoolUserDetails'][number],
1143
+ chainBalance: BigNumber,
1144
+ blockTag: number | undefined,
1145
+ provider?: Provider,
1146
+ ): Promise<{
1147
+ chainBalance: BigNumber;
1148
+ lifetime: number;
1149
+ tranches: Map<string, { balance: string; yieldEarned: number }>;
1150
+ mismatch?: LedgerMismatch;
1151
+ }> {
1152
+ const decimals = this._kasuConfig.stableAssetDecimals;
1153
+ const chainTotal = parseFloat(formatUnits(chainBalance, decimals));
1154
+ const lifetime =
1155
+ chainTotal -
1156
+ parseFloat(details.totalAcceptedDeposits) +
1157
+ parseFloat(details.totalAcceptedWithdrawnAmount);
1158
+
1159
+ const tranches = new Map<
1160
+ string,
1161
+ { balance: string; yieldEarned: number }
1162
+ >();
1163
+ for (const position of details.lendingPoolTrancheUserDetails) {
1164
+ const balance = this._userLendingService.convertSharesToAssets(
1165
+ position.shares,
1166
+ position.tranche.balance,
1167
+ position.tranche.shares,
1168
+ );
1169
+ tranches.set(position.tranche.id, {
1170
+ balance,
1171
+ yieldEarned:
1172
+ parseFloat(balance) -
1173
+ parseFloat(position.totalAcceptedDeposits) +
1174
+ parseFloat(position.totalAcceptedWithdrawnAmount),
1175
+ });
1176
+ }
1177
+
1178
+ const ledgerTotal = [...tranches.values()].reduce(
1179
+ (sum, { balance }) => sum + parseFloat(balance),
1180
+ 0,
1181
+ );
1182
+ const negative = [...tranches.values()].some(
1183
+ ({ balance }) => parseFloat(balance) < 0,
1184
+ );
1185
+ const disagrees =
1186
+ blockTag !== undefined && !ledgerAgrees(ledgerTotal, chainTotal);
1187
+ if (!negative && !disagrees) {
1188
+ return { chainBalance, lifetime, tranches };
1189
+ }
1190
+
1191
+ const trancheIds = [
1192
+ ...new Set([
1193
+ ...overview.tranches.map(({ id }) => id),
1194
+ ...tranches.keys(),
1195
+ ]),
1196
+ ];
1197
+ const onChain =
1198
+ await this._userLendingService.getUserTrancheActiveAssets(
1199
+ userAddress,
1200
+ trancheIds,
1201
+ provider,
1202
+ blockTag,
1203
+ );
1204
+
1205
+ let phantom = false;
1206
+ const reconciled = new Map<
1207
+ string,
1208
+ { balance: string; yieldEarned: number }
1209
+ >();
1210
+ for (const [index, trancheId] of trancheIds.entries()) {
1211
+ const fromLedger = tranches.get(trancheId);
1212
+ const ledgerBalance = parseFloat(fromLedger?.balance ?? '0');
1213
+ const balance = formatUnits(
1214
+ onChain[index] ?? BigNumber.from(0),
1215
+ decimals,
1216
+ );
1217
+ if (
1218
+ blockTag !== undefined &&
1219
+ ledgerBalance > parseFloat(balance) &&
1220
+ !ledgerAgrees(ledgerBalance, parseFloat(balance))
1221
+ ) {
1222
+ phantom = true;
1223
+ }
1224
+ reconciled.set(trancheId, {
1225
+ balance,
1226
+ yieldEarned:
1227
+ (fromLedger?.yieldEarned ?? 0) +
1228
+ parseFloat(balance) -
1229
+ ledgerBalance,
1230
+ });
1231
+ }
1232
+
1233
+ return {
1234
+ chainBalance,
1235
+ lifetime,
1236
+ tranches: reconciled,
1237
+ mismatch: {
1238
+ kind: phantom ? 'ledger-exceeds-chain' : 'chain-exceeds-ledger',
1239
+ ledgerBalance: String(ledgerTotal),
1240
+ chainBalance: String(chainTotal),
1241
+ },
1242
+ };
1243
+ }
1244
+
1245
+ /**
1246
+ * The last-epoch figures of positions whose newest interest is OLDER than
1247
+ * the recent window. Pools clear independently, and some clear only when
1248
+ * the originator repays, so a pool's last cycle can be weeks back: it is
1249
+ * priced at THAT epoch, from a history read of just those positions over
1250
+ * just those epochs, through the same `positionEpochYield` — so it equals
1251
+ * the history's newest bar.
1252
+ *
1253
+ * A lender who has since left the tranche (no ledger shares now) gets no
1254
+ * figure: an old pool's last cycle is not theirs to show. Costs nothing
1255
+ * when no position qualifies.
1256
+ */
1257
+ private async staleLastEpochYields(
1258
+ positions: {
1259
+ poolId: string;
1260
+ position: NonNullable<
1261
+ LendingPortfolioQueryResult['user']
1262
+ >['lendingPoolUserDetails'][number]['lendingPoolTrancheUserDetails'][number];
1263
+ }[],
1264
+ recentEpoch: number,
1265
+ ): Promise<Map<string, EpochTrancheYield>> {
1266
+ const stale = positions.flatMap(({ poolId, position }) => {
1267
+ const interest =
1268
+ position.tranche.lendingPoolTrancheEpochInterest?.[0];
1269
+ if (interest?.epochId === undefined) return [];
1270
+ if (!(parseFloat(position.shares) > 0)) return [];
1271
+ const epoch = Number(interest.epochId);
1272
+ return epoch < recentEpoch
1273
+ ? [{ poolId, position, epoch, interest }]
1274
+ : [];
1275
+ });
1276
+ const yields = new Map<string, EpochTrancheYield>();
1277
+ if (stale.length === 0) return yields;
1278
+
1279
+ const lockId = (
1280
+ poolId: string,
1281
+ lock: { id?: string; lockId: string },
1282
+ ): string => lock.id ?? `${poolId.toLowerCase()}-${lock.lockId}`;
1283
+
1284
+ const { rows } = await fetchYieldHistorySeries(
1285
+ (variables) =>
1286
+ this._graph.request<YieldHistoryPageResult>(
1287
+ yieldHistoryPageQuery,
1288
+ variables,
1289
+ ),
1290
+ {
1291
+ pools: [...new Set(stale.map(({ poolId }) => poolId))].map(
1292
+ (id) => id.toLowerCase(),
1293
+ ),
1294
+ tranches: [
1295
+ ...new Set(
1296
+ stale.map(({ position }) => position.tranche.id),
1297
+ ),
1298
+ ],
1299
+ userTranches: stale.map(({ position }) => position.id),
1300
+ locks: stale.flatMap(({ poolId, position }) =>
1301
+ position.userLendingPoolTrancheFixedTermDepositLocks.map(
1302
+ (lock) => lockId(poolId, lock),
1303
+ ),
1304
+ ),
1305
+ },
1306
+ {
1307
+ fromEpoch: Math.min(...stale.map(({ epoch }) => epoch)),
1308
+ toEpoch: Math.max(...stale.map(({ epoch }) => epoch)),
1309
+ },
1310
+ );
1311
+ const starts = clearingStarts(rows.clearings);
1312
+
1313
+ for (const { poolId, position, epoch, interest } of stale) {
1314
+ const series = positionSeriesFrom(rows, {
1315
+ ...position,
1316
+ userLendingPoolTrancheFixedTermDepositLocks:
1317
+ position.userLendingPoolTrancheFixedTermDepositLocks.map(
1318
+ (lock) => ({ ...lock, id: lockId(poolId, lock) }),
1319
+ ),
1320
+ });
1321
+ yields.set(
1322
+ position.id,
1323
+ positionEpochYield(
1324
+ series,
1325
+ epoch,
1326
+ parseFloat(interest.epochInterestAmount),
1327
+ starts.get(poolId.toLowerCase())?.get(epoch),
1328
+ ).yield,
1329
+ );
1330
+ }
1331
+ return yields;
1332
+ }
1333
+
901
1334
  /**
902
1335
  * A money figure as a plain decimal string. Below a millionth of a token
903
1336
  * (the stable asset's own precision) is float noise, never money, so it