@kasufinance/kasu-sdk 2.15.0 → 2.16.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.
@@ -8,18 +8,69 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  });
9
9
  };
10
10
  import { BigNumber } from 'ethers';
11
- import { formatEther, parseEther, parseUnits } from 'ethers/lib/utils';
11
+ import { formatEther, formatUnits, parseEther, parseUnits, } from 'ethers/lib/utils';
12
12
  import { GraphQLClient } from 'graphql-request';
13
13
  import { IKasuNFTsAbi__factory, ISystemVariablesAbi__factory, IUserLoyaltyRewardsAbi__factory, } from '../../contracts';
14
- import { epochTrancheYield, } from '../../domain/yield-history';
15
14
  import { isLiteDeployment } from '../../utils/deployment-mode';
16
15
  import { DataService } from '../DataService/data-service';
17
16
  import { getAllTrancheConfigurationsQuery, getAllTranchesQuery, } from '../DataService/queries';
18
17
  import { KSULocking } from '../Locking/locking';
19
18
  import { getSystemVariablesQuery, lockingSummariesQuery, } from '../Locking/queries';
19
+ import { isBlockUnavailableError } from '../shared';
20
20
  import { UserLending } from '../UserLending/user-lending';
21
21
  import { lendingPortfolioQuery } from './queries';
22
- import { buildYieldHistory, fetchYieldHistorySeries, isEmptyHistoryError, resolveYieldHistoryWindow, validateYieldHistoryOptions, YIELD_HISTORY_PAGE_SIZE, yieldHistoryPageQuery, yieldHistoryPositionsQuery, } from './yield-history';
22
+ import { buildYieldHistory, clearingStarts, fetchYieldHistorySeries, isEmptyHistoryError, positionEpochYield, positionSeriesFrom, resolveYieldHistoryWindow, validateYieldHistoryOptions, YIELD_HISTORY_PAGE_SIZE, yieldHistoryPageQuery, yieldHistoryPositionsQuery, } from './yield-history';
23
+ /**
24
+ * How many epochs back the portfolio read pairs a tranche's newest interest
25
+ * with the shares in force at its clearing (`lendingPortfolioQuery`): the last
26
+ * six settled epochs, so a pool whose clearings run late or lag a few weeks
27
+ * behind the others (Base has one) is paired too. A pool whose newest
28
+ * interest is older (pools clear independently; some only when the
29
+ * originator repays) is priced from a history read of its own, at that
30
+ * epoch. The extra rows are a few per lender and tranche.
31
+ */
32
+ const RECENT_PAIRING_EPOCHS = 6;
33
+ /**
34
+ * The ledger (subgraph) and the chain agree on a balance AT THE SAME BLOCK
35
+ * within a cent, or a millionth of it — the rounding of two share-price
36
+ * conversions.
37
+ */
38
+ const BALANCE_RECONCILE_TOLERANCE = 0.01;
39
+ const BALANCE_RECONCILE_RELATIVE = 1e-6;
40
+ function ledgerAgrees(ledger, chain) {
41
+ return (Math.abs(ledger - chain) <=
42
+ Math.max(BALANCE_RECONCILE_TOLERANCE, Math.abs(chain) * BALANCE_RECONCILE_RELATIVE));
43
+ }
44
+ /**
45
+ * A list read with `first: $first` that comes back FULL may have been cut:
46
+ * refuse it rather than price from a truncated read.
47
+ */
48
+ function refuseTruncated(data) {
49
+ var _a, _b;
50
+ const full = (rows) => { var _a; return ((_a = rows === null || rows === void 0 ? void 0 : rows.length) !== null && _a !== void 0 ? _a : 0) >= YIELD_HISTORY_PAGE_SIZE; };
51
+ const lists = [
52
+ ['lendingPoolClearings', full(data.lendingPoolClearings)],
53
+ ['recentWithdrawalsAccepted', full(data.recentWithdrawalsAccepted)],
54
+ ];
55
+ for (const pool of (_b = (_a = data.user) === null || _a === void 0 ? void 0 : _a.lendingPoolUserDetails) !== null && _b !== void 0 ? _b : []) {
56
+ for (const position of pool.lendingPoolTrancheUserDetails) {
57
+ lists.push([
58
+ 'recentUserShareUpdates',
59
+ full(position.recentUserShareUpdates),
60
+ ], [
61
+ 'recentTrancheShareUpdates',
62
+ full(position.tranche.recentTrancheShareUpdates),
63
+ ], ...position.userLendingPoolTrancheFixedTermDepositLocks.map((lock) => [
64
+ 'lockSharesRecent',
65
+ full(lock.lockSharesRecent),
66
+ ]));
67
+ }
68
+ }
69
+ const cut = lists.find(([, isFull]) => isFull);
70
+ if (cut) {
71
+ throw new Error(`getPortfolioLendingData: ${cut[0]} reached ${String(YIELD_HISTORY_PAGE_SIZE)} rows; refusing a possibly truncated read`);
72
+ }
73
+ }
23
74
  export class Portfolio {
24
75
  constructor(_kasuConfig, signerOrProvider) {
25
76
  this._kasuConfig = _kasuConfig;
@@ -237,23 +288,52 @@ export class Portfolio {
237
288
  }
238
289
  getPortfolioLendingData(userAddress, poolOverviews, currentEpoch, provider) {
239
290
  return __awaiter(this, void 0, void 0, function* () {
240
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
291
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
241
292
  const SECONDS_PER_EPOCH = 604800; // seconds in a week
242
293
  const portfolioLendingPoolsPromise = [];
243
294
  const previousEpoch = BigNumber.from(currentEpoch).sub(BigNumber.from(1));
244
- const [lastEpochData, portfolioUserTrancheBalances, userPoolBalances] = yield Promise.all([
245
- this._graph.request(lendingPortfolioQuery, {
246
- userAddress: userAddress.toLowerCase(),
247
- epochId: parseFloat(currentEpoch),
248
- lastEpochId: previousEpoch.toNumber(),
249
- unusedPools: this._kasuConfig.UNUSED_LENDING_POOL_IDS,
250
- first: YIELD_HISTORY_PAGE_SIZE,
251
- }),
252
- this._userLendingService.getPortfolioUserTrancheBalances(userAddress),
253
- this._userLendingService.getUserPoolBalance(userAddress, poolOverviews.map(({ id }) => id), provider),
254
- ]);
295
+ const recentEpoch = Math.max(0, previousEpoch.toNumber() - (RECENT_PAIRING_EPOCHS - 1));
296
+ const unused = new Set(this._kasuConfig.UNUSED_LENDING_POOL_IDS.map((id) => id.toLowerCase()));
297
+ const listedPools = poolOverviews.filter(({ id }) => !unused.has(id.toLowerCase()));
298
+ const lastEpochData = yield this._graph.request(lendingPortfolioQuery, {
299
+ userAddress: userAddress.toLowerCase(),
300
+ epochId: parseFloat(currentEpoch),
301
+ lastEpochId: previousEpoch.toNumber(),
302
+ recentEpoch,
303
+ lockRecentEpoch: Math.max(0, recentEpoch - 1),
304
+ unusedPools: this._kasuConfig.UNUSED_LENDING_POOL_IDS,
305
+ poolIds: listedPools.map(({ id }) => id.toLowerCase()),
306
+ trancheIds: listedPools.flatMap(({ tranches }) => tranches.map(({ id }) => id.toLowerCase())),
307
+ first: YIELD_HISTORY_PAGE_SIZE,
308
+ });
255
309
  if (!lastEpochData.user)
256
310
  return [];
311
+ refuseTruncated(lastEpochData);
312
+ // The pools the lender has a ledger in AND that are listed: the only
313
+ // ones read on chain, and the only ones reported.
314
+ const userPools = listedPools.flatMap((overview) => {
315
+ var _a;
316
+ const details = (_a = lastEpochData.user) === null || _a === void 0 ? void 0 : _a.lendingPoolUserDetails.find(({ lendingPool }) => lendingPool.id.toLowerCase() === overview.id.toLowerCase());
317
+ return details ? [{ overview, details }] : [];
318
+ });
319
+ const recentStarts = clearingStarts(lastEpochData.lendingPoolClearings);
320
+ const recentExits = new Map();
321
+ for (const exit of lastEpochData.recentWithdrawalsAccepted) {
322
+ const list = (_a = recentExits.get(exit.tranche.id)) !== null && _a !== void 0 ? _a : [];
323
+ list.push(exit);
324
+ recentExits.set(exit.tranche.id, list);
325
+ }
326
+ const [chain, staleYields] = yield Promise.all([
327
+ this.readChainAt((_b = lastEpochData._meta) === null || _b === void 0 ? void 0 : _b.block.number, (blockTag) => this._userLendingService.getUserPoolBalancesAt(userAddress, userPools.map(({ overview }) => overview.id), provider, blockTag)),
328
+ this.staleLastEpochYields(userPools.flatMap(({ overview, details }) => details.lendingPoolTrancheUserDetails.map((position) => ({
329
+ poolId: overview.id,
330
+ position,
331
+ }))), recentEpoch),
332
+ ]);
333
+ const reconciled = yield Promise.all(userPools.map(({ overview, details }, index) => {
334
+ var _a;
335
+ return this.reconcilePool(userAddress, overview, details, (_a = chain.values[index]) !== null && _a !== void 0 ? _a : BigNumber.from(0), chain.blockTag, provider);
336
+ }));
257
337
  const userRequestsMap = new Map();
258
338
  for (const userRequest of lastEpochData.userRequests) {
259
339
  const trancheId = userRequest.tranche.id;
@@ -279,28 +359,22 @@ export class Portfolio {
279
359
  continue;
280
360
  }
281
361
  userRequestsMap.set(trancheId, Object.assign(Object.assign({}, mappedUserRequest), { [ftdId]: [
282
- ...((_a = mappedUserRequest[ftdId]) !== null && _a !== void 0 ? _a : []),
362
+ ...((_c = mappedUserRequest[ftdId]) !== null && _c !== void 0 ? _c : []),
283
363
  userRequestFtdItem,
284
364
  ] }));
285
365
  }
286
366
  }
287
- let totalInvestments = BigNumber.from(0);
288
- for (const poolOverview of poolOverviews) {
289
- const userPoolBalance = userPoolBalances.find(({ poolId }) => poolId === poolOverview.id);
290
- if (!userPoolBalance) {
291
- continue;
292
- }
293
- totalInvestments = userPoolBalance.balance.add(totalInvestments);
367
+ for (const [poolIndex, { overview: poolOverview, details },] of userPools.entries()) {
368
+ const pool = reconciled[poolIndex];
294
369
  const tranches = [];
295
370
  let poolYieldEarnedLastEpoch = 0;
296
- const lendingPoolUserDetails = lastEpochData.user.lendingPoolUserDetails.find(({ lendingPool }) => lendingPool.id === poolOverview.id);
297
- if (!lendingPoolUserDetails)
298
- continue;
299
- const portfolioUserTrancheBalance = portfolioUserTrancheBalances.get(poolOverview.id);
371
+ // A ledger crediting shares the wallet does not hold earns
372
+ // nothing we can vouch for: its last-cycle figures are withheld.
373
+ const withheld = ((_d = pool.mismatch) === null || _d === void 0 ? void 0 : _d.kind) === 'ledger-exceeds-chain';
300
374
  for (const tranche of poolOverview.tranches) {
301
- const lastEpochUserTrancheDetails = lendingPoolUserDetails.lendingPoolTrancheUserDetails.find((lendingPool) => lendingPool.tranche.id === tranche.id);
375
+ const lastEpochUserTrancheDetails = details.lendingPoolTrancheUserDetails.find((lendingPool) => lendingPool.tranche.id === tranche.id);
302
376
  const userRequest = userRequestsMap.get(tranche.id);
303
- const depositDetails = (_b = userRequest === null || userRequest === void 0 ? void 0 : userRequest['0']) !== null && _b !== void 0 ? _b : [];
377
+ const depositDetails = (_e = userRequest === null || userRequest === void 0 ? void 0 : userRequest['0']) !== null && _e !== void 0 ? _e : [];
304
378
  if (!lastEpochUserTrancheDetails) {
305
379
  tranches.push(Object.assign(Object.assign({}, tranche), { investedAmount: {
306
380
  balance: '0',
@@ -311,55 +385,61 @@ export class Portfolio {
311
385
  }, depositDetails, fixedLoans: [] }));
312
386
  continue;
313
387
  }
314
- const lastEpoch = previousEpoch.toNumber();
315
- const userHasLastEpochShares = Boolean((_c = lastEpochUserTrancheDetails
316
- .lendingPoolTrancheUserEpochSharesUpdates) === null || _c === void 0 ? void 0 : _c.length);
388
+ // The tranche's newest interest (epoch E), priced with the
389
+ // shares in force when clearing E applied it — the one
390
+ // selection `getYieldHistory` makes (`positionEpochYield`).
391
+ // Inside the recent window the candidates below are complete.
392
+ // An older E (a pool that clears rarely — some only when the
393
+ // originator repays) is priced from a history read of its own,
394
+ // and is 0 for a lender who has left the tranche.
395
+ const newestInterest = (_f = lastEpochUserTrancheDetails.tranche
396
+ .lendingPoolTrancheEpochInterest) === null || _f === void 0 ? void 0 : _f[0];
397
+ const interestEpoch = (newestInterest === null || newestInterest === void 0 ? void 0 : newestInterest.epochId) === undefined
398
+ ? undefined
399
+ : Number(newestInterest.epochId);
317
400
  const fixedTermDeposits = lastEpochUserTrancheDetails.userLendingPoolTrancheFixedTermDepositLocks;
318
- // The one per-epoch formula `getYieldHistory` runs too, with
319
- // the same share selection — so the last bar of a history
320
- // equals these figures.
321
- const lastEpochYield = epochTrancheYield({
322
- epochInterest: parseFloat((_f = (_e = (_d = lastEpochUserTrancheDetails.tranche
323
- .lendingPoolTrancheEpochInterest) === null || _d === void 0 ? void 0 : _d[0]) === null || _e === void 0 ? void 0 : _e.epochInterestAmount) !== null && _f !== void 0 ? _f : '0'),
324
- userShares: parseFloat((_j = (_h = (_g = lastEpochUserTrancheDetails
325
- .lendingPoolTrancheUserEpochSharesUpdates) === null || _g === void 0 ? void 0 : _g[0]) === null || _h === void 0 ? void 0 : _h.shares) !== null && _j !== void 0 ? _j : '0'),
326
- trancheShares: parseFloat((_m = (_l = (_k = lastEpochUserTrancheDetails.tranche
327
- .lendingPoolTrancheShareUpdates) === null || _k === void 0 ? void 0 : _k[0]) === null || _l === void 0 ? void 0 : _l.shares) !== null && _m !== void 0 ? _m : '0'),
328
- trancheBalanceNow: parseFloat(lastEpochUserTrancheDetails.tranche.balance),
329
- trancheSharesNow: parseFloat(lastEpochUserTrancheDetails.tranche.shares),
330
- locks: fixedTermDeposits.map((fixedTermDeposit) => {
331
- var _a, _b, _c, _d, _e, _f;
332
- return ({
333
- active: parseFloat(fixedTermDeposit.epochLockStart) <
334
- lastEpoch &&
335
- parseFloat(fixedTermDeposit.epochLockEnd) >=
336
- lastEpoch &&
337
- userHasLastEpochShares,
338
- // No snapshot = the lock's opening shares, exactly
339
- // as `getYieldHistory` reads it: a lock made after
340
- // clearing E starts at E with its first update
341
- // tagged E + 1. Never the CURRENT shares, which
342
- // would carry a top-up from a clearing that ran
343
- // after the last settled epoch.
344
- sharesAtEpoch: parseFloat((_c = (_b = (_a = fixedTermDeposit.lockSharesAtLastEpoch) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.shares) !== null && _c !== void 0 ? _c : fixedTermDeposit.initialTrancheShares),
345
- sharesBeforeEpoch: parseFloat((_f = (_e = (_d = fixedTermDeposit
346
- .userLendingPoolTrancheFixedTermDepositLockShareUpdate) === null || _d === void 0 ? void 0 : _d[0]) === null || _e === void 0 ? void 0 : _e.shares) !== null && _f !== void 0 ? _f : fixedTermDeposit.initialTrancheShares),
347
- });
348
- }),
349
- });
350
- const userTrancheBalance = {
351
- balance: '0',
352
- yieldEarned: 0,
401
+ const noYield = {
402
+ total: 0,
403
+ variable: 0,
404
+ locks: fixedTermDeposits.map(() => 0),
353
405
  };
354
- if (portfolioUserTrancheBalance) {
355
- const portfolioUserTranche = portfolioUserTrancheBalance.find(({ trancheId }) => trancheId === tranche.id);
356
- if (portfolioUserTranche) {
357
- userTrancheBalance.balance =
358
- portfolioUserTranche.userBalance;
359
- userTrancheBalance.yieldEarned =
360
- portfolioUserTranche.yieldEarned;
361
- }
406
+ let lastEpochYield = noYield;
407
+ if (interestEpoch !== undefined &&
408
+ interestEpoch < recentEpoch &&
409
+ !withheld) {
410
+ lastEpochYield =
411
+ (_g = staleYields.get(lastEpochUserTrancheDetails.id)) !== null && _g !== void 0 ? _g : noYield;
412
+ }
413
+ else if (interestEpoch !== undefined && !withheld) {
414
+ lastEpochYield = positionEpochYield({
415
+ trancheBalanceNow: parseFloat(lastEpochUserTrancheDetails.tranche.balance),
416
+ trancheSharesNow: parseFloat(lastEpochUserTrancheDetails.tranche.shares),
417
+ userUpdates: [
418
+ ...((_h = lastEpochUserTrancheDetails.lendingPoolTrancheUserEpochSharesUpdates) !== null && _h !== void 0 ? _h : []),
419
+ ...((_j = lastEpochUserTrancheDetails.recentUserShareUpdates) !== null && _j !== void 0 ? _j : []),
420
+ ],
421
+ trancheUpdates: [
422
+ ...((_k = lastEpochUserTrancheDetails.tranche
423
+ .lendingPoolTrancheShareUpdates) !== null && _k !== void 0 ? _k : []),
424
+ ...((_l = lastEpochUserTrancheDetails.tranche
425
+ .recentTrancheShareUpdates) !== null && _l !== void 0 ? _l : []),
426
+ ],
427
+ exits: (_m = recentExits.get(tranche.id)) !== null && _m !== void 0 ? _m : [],
428
+ locks: fixedTermDeposits.map((lock) => {
429
+ var _a, _b;
430
+ return (Object.assign(Object.assign({}, lock), { updates: [
431
+ ...((_a = lock.lockSharesBase) !== null && _a !== void 0 ? _a : []),
432
+ ...((_b = lock.lockSharesRecent) !== null && _b !== void 0 ? _b : []),
433
+ ] }));
434
+ }),
435
+ }, interestEpoch, parseFloat((_o = newestInterest === null || newestInterest === void 0 ? void 0 : newestInterest.epochInterestAmount) !== null && _o !== void 0 ? _o : '0'), (_p = recentStarts
436
+ .get(poolOverview.id.toLowerCase())) === null || _p === void 0 ? void 0 : _p.get(interestEpoch)).yield;
362
437
  }
438
+ const ledger = pool.tranches.get(tranche.id);
439
+ const userTrancheBalance = {
440
+ balance: (_q = ledger === null || ledger === void 0 ? void 0 : ledger.balance) !== null && _q !== void 0 ? _q : '0',
441
+ yieldEarned: (_r = ledger === null || ledger === void 0 ? void 0 : ledger.yieldEarned) !== null && _r !== void 0 ? _r : 0,
442
+ };
363
443
  let totalLifetimeFtdYield = 0;
364
444
  const fixedLoans = fixedTermDeposits.map((fixedTermDeposit, lockIndex) => {
365
445
  var _a, _b;
@@ -407,7 +487,9 @@ export class Portfolio {
407
487
  }, fixedLoans,
408
488
  depositDetails }));
409
489
  }
410
- portfolioLendingPoolsPromise.push(Object.assign(Object.assign({}, poolOverview), { totalYieldEarningsLastEpoch: this.precisionToString(poolYieldEarnedLastEpoch), totalInvestedAmount: userPoolBalance.balance.toString(), totalYieldEarningsLifetime: userPoolBalance.yieldEarned.toString(), tranches }));
490
+ portfolioLendingPoolsPromise.push(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, poolOverview), { totalYieldEarningsLastEpoch: this.precisionToString(poolYieldEarnedLastEpoch), totalInvestedAmount: pool.chainBalance.toString(), totalYieldEarningsLifetime: pool.lifetime.toString() }), (pool.mismatch ? { ledgerMismatch: pool.mismatch } : {})), (chain.blockTag === undefined
491
+ ? { chainPinned: false }
492
+ : {})), { tranches }));
411
493
  }
412
494
  const portfolioLendingPools = portfolioLendingPoolsPromise.map((pool) => (Object.assign(Object.assign({}, pool), { tranches: pool.tranches.map((tranche) => {
413
495
  const trancheBalance = tranche.investedAmount;
@@ -418,7 +500,14 @@ export class Portfolio {
418
500
  }, BigNumber.from(0));
419
501
  const totalTrancheYieldEarned = trancheBalance.yieldEarned.toFixed(6);
420
502
  const totalTrancheFtdYieldEarned = parseFloat(tranche.yieldEarnings.lifetime).toFixed(6);
421
- return Object.assign(Object.assign({}, tranche), { investedAmount: formatEther(parseEther(trancheBalance.balance).sub(lockedAmount)), yieldEarnings: Object.assign(Object.assign({}, tranche.yieldEarnings), { lifetime: this.precisionToString(parseFloat(totalTrancheYieldEarned) -
503
+ // Never negative: a variable balance below the locked
504
+ // amount is a ledger the chain does not back (the pool
505
+ // carries `ledgerMismatch`), not money owed — flagged.
506
+ const variableBalance = parseEther(trancheBalance.balance).sub(lockedAmount);
507
+ const floored = variableBalance.isNegative();
508
+ return Object.assign(Object.assign(Object.assign(Object.assign({}, tranche), { investedAmount: formatEther(floored ? BigNumber.from(0) : variableBalance) }), (floored
509
+ ? { investedAmountFloored: true }
510
+ : {})), { yieldEarnings: Object.assign(Object.assign({}, tranche.yieldEarnings), { lifetime: this.precisionToString(parseFloat(totalTrancheYieldEarned) -
422
511
  parseFloat(totalTrancheFtdYieldEarned)) }) });
423
512
  }) })));
424
513
  return portfolioLendingPools.filter((pool) => !parseUnits(pool.totalInvestedAmount).isZero() ||
@@ -444,7 +533,12 @@ export class Portfolio {
444
533
  * Cost: three parallel `SystemVariables` reads and the positions query in
445
534
  * one round, then one subgraph request per page of the longest series
446
535
  * (1000 rows a page) — normally two subgraph round trips in all, never one
447
- * per epoch.
536
+ * per epoch — alongside one `LendingPool.userBalance` call per pool at
537
+ * the subgraph's block. A pool whose ledger credits more than the wallet
538
+ * holds at that block (shares moved away outside a withdrawal) is left
539
+ * out; if the node says it cannot serve that block, `latest` is read
540
+ * and nothing is. Any other failure of those reads (a rate limit, a
541
+ * network error) THROWS: the history needs a working RPC.
448
542
  *
449
543
  * On a LITE deployment, a subgraph that lacks the entities (a validation
450
544
  * error naming them) yields `[]`; on a full deployment that error throws,
@@ -455,6 +549,7 @@ export class Portfolio {
455
549
  */
456
550
  getYieldHistory(userAddress_1) {
457
551
  return __awaiter(this, arguments, void 0, function* (userAddress, options = {}) {
552
+ var _a;
458
553
  validateYieldHistoryOptions(options);
459
554
  try {
460
555
  const [currentEpoch, epochZeroStart, epochDuration, positions] = yield Promise.all([
@@ -473,7 +568,9 @@ export class Portfolio {
473
568
  const userTranches = [];
474
569
  const tranches = new Set();
475
570
  const locks = [];
571
+ const pools = [];
476
572
  for (const pool of positions.user.lendingPoolUserDetails) {
573
+ pools.push(pool.lendingPool.id);
477
574
  for (const position of pool.lendingPoolTrancheUserDetails) {
478
575
  userTranches.push(position.id);
479
576
  tranches.add(position.tranche.id);
@@ -484,11 +581,33 @@ export class Portfolio {
484
581
  }
485
582
  if (userTranches.length === 0)
486
583
  return [];
487
- const { rows } = yield fetchYieldHistorySeries((variables) => this._graph.request(yieldHistoryPageQuery, variables), { tranches: [...tranches], userTranches, locks }, window);
584
+ const [{ rows }, chain] = yield Promise.all([
585
+ fetchYieldHistorySeries((variables) => this._graph.request(yieldHistoryPageQuery, variables), { pools, tranches: [...tranches], userTranches, locks }, window),
586
+ this.readChainAt((_a = positions._meta) === null || _a === void 0 ? void 0 : _a.block.number, (blockTag) => this._userLendingService.getUserPoolBalancesAt(userAddress, pools, undefined, blockTag)),
587
+ ]);
588
+ // A pool whose ledger credits shares the wallet does not hold at
589
+ // the same block (shares moved away outside a withdrawal) has no
590
+ // history we can vouch for: it is left out. Unpinned, nothing is.
591
+ const decimals = this._kasuConfig.stableAssetDecimals;
592
+ const withheld = new Set();
593
+ if (chain.blockTag !== undefined) {
594
+ positions.user.lendingPoolUserDetails.forEach((pool, index) => {
595
+ var _a;
596
+ const ledger = pool.lendingPoolTrancheUserDetails.reduce((sum, position) => {
597
+ var _a;
598
+ return sum +
599
+ parseFloat(this._userLendingService.convertSharesToAssets((_a = position.shares) !== null && _a !== void 0 ? _a : '0', position.tranche.balance, position.tranche.shares));
600
+ }, 0);
601
+ const held = parseFloat(formatUnits((_a = chain.values[index]) !== null && _a !== void 0 ? _a : BigNumber.from(0), decimals));
602
+ if (ledger > held && !ledgerAgrees(ledger, held)) {
603
+ withheld.add(pool.lendingPool.id.toLowerCase());
604
+ }
605
+ });
606
+ }
488
607
  return buildYieldHistory(Object.assign({ positions }, rows), window, {
489
608
  epochZeroStart: epochZeroStart.toNumber(),
490
609
  epochDuration: epochDuration.toNumber(),
491
- });
610
+ }).filter((entry) => !withheld.has(entry.poolId.toLowerCase()));
492
611
  }
493
612
  catch (error) {
494
613
  if (isEmptyHistoryError(error, this._isLiteDeployment))
@@ -497,6 +616,164 @@ export class Portfolio {
497
616
  }
498
617
  });
499
618
  }
619
+ /**
620
+ * Read the chain at the subgraph's block, so the chain and the ledger
621
+ * describe the same state: after every clearing the subgraph runs a few
622
+ * blocks behind, and a `latest` read would then disagree with an
623
+ * honest ledger (interest moved every balance, deposits were accepted)
624
+ * and book that difference as a ledger fault.
625
+ *
626
+ * Only when the node says it cannot serve that block (pruned, or behind
627
+ * the subgraph: `isBlockUnavailableError`) does it fall back to `latest`,
628
+ * and it says so (`blockTag: undefined`): unpinned figures are never
629
+ * compared with the ledger, and `getPortfolioLendingData` marks the pools
630
+ * `chainPinned: false`. Any other failure — a rate limit, a timeout, a
631
+ * network error — is thrown, never turned into an unpinned read.
632
+ */
633
+ readChainAt(block, read) {
634
+ return __awaiter(this, void 0, void 0, function* () {
635
+ if (block !== undefined) {
636
+ try {
637
+ return { values: yield read(block), blockTag: block };
638
+ }
639
+ catch (error) {
640
+ if (!isBlockUnavailableError(error))
641
+ throw error;
642
+ }
643
+ }
644
+ return { values: yield read(undefined), blockTag: undefined };
645
+ });
646
+ }
647
+ /**
648
+ * One pool's balances: the ledger's (the subgraph counts a lender's
649
+ * shares as accepted deposits − accepted withdrawals ± top-ups) checked
650
+ * against the chain's `LendingPool.userBalance` at the SAME block.
651
+ *
652
+ * The ledger does not follow shares that move between wallets any other
653
+ * way (an administrative move of a position is the case seen on chain):
654
+ * the receiving wallet goes negative as it withdraws, the sending one
655
+ * keeps shares it no longer holds. Where the two disagree at that block —
656
+ * or any ledger balance is negative — the tranche balances are read from
657
+ * the chain at that block (`userActiveAssets`, one call per tranche), the
658
+ * tranche's ledger `yieldEarned` moves by the same difference, and the
659
+ * pool carries `ledgerMismatch`. Unpinned (the node could not serve the
660
+ * block), only a negative ledger balance is corrected. A pool that agrees
661
+ * — every ordinary lender — costs nothing extra.
662
+ *
663
+ * Lifetime figures stay ledger figures: they cannot tell moved principal
664
+ * from interest.
665
+ */
666
+ reconcilePool(userAddress, overview, details, chainBalance, blockTag, provider) {
667
+ return __awaiter(this, void 0, void 0, function* () {
668
+ var _a, _b, _c;
669
+ const decimals = this._kasuConfig.stableAssetDecimals;
670
+ const chainTotal = parseFloat(formatUnits(chainBalance, decimals));
671
+ const lifetime = chainTotal -
672
+ parseFloat(details.totalAcceptedDeposits) +
673
+ parseFloat(details.totalAcceptedWithdrawnAmount);
674
+ const tranches = new Map();
675
+ for (const position of details.lendingPoolTrancheUserDetails) {
676
+ const balance = this._userLendingService.convertSharesToAssets(position.shares, position.tranche.balance, position.tranche.shares);
677
+ tranches.set(position.tranche.id, {
678
+ balance,
679
+ yieldEarned: parseFloat(balance) -
680
+ parseFloat(position.totalAcceptedDeposits) +
681
+ parseFloat(position.totalAcceptedWithdrawnAmount),
682
+ });
683
+ }
684
+ const ledgerTotal = [...tranches.values()].reduce((sum, { balance }) => sum + parseFloat(balance), 0);
685
+ const negative = [...tranches.values()].some(({ balance }) => parseFloat(balance) < 0);
686
+ const disagrees = blockTag !== undefined && !ledgerAgrees(ledgerTotal, chainTotal);
687
+ if (!negative && !disagrees) {
688
+ return { chainBalance, lifetime, tranches };
689
+ }
690
+ const trancheIds = [
691
+ ...new Set([
692
+ ...overview.tranches.map(({ id }) => id),
693
+ ...tranches.keys(),
694
+ ]),
695
+ ];
696
+ const onChain = yield this._userLendingService.getUserTrancheActiveAssets(userAddress, trancheIds, provider, blockTag);
697
+ let phantom = false;
698
+ const reconciled = new Map();
699
+ for (const [index, trancheId] of trancheIds.entries()) {
700
+ const fromLedger = tranches.get(trancheId);
701
+ const ledgerBalance = parseFloat((_a = fromLedger === null || fromLedger === void 0 ? void 0 : fromLedger.balance) !== null && _a !== void 0 ? _a : '0');
702
+ const balance = formatUnits((_b = onChain[index]) !== null && _b !== void 0 ? _b : BigNumber.from(0), decimals);
703
+ if (blockTag !== undefined &&
704
+ ledgerBalance > parseFloat(balance) &&
705
+ !ledgerAgrees(ledgerBalance, parseFloat(balance))) {
706
+ phantom = true;
707
+ }
708
+ reconciled.set(trancheId, {
709
+ balance,
710
+ yieldEarned: ((_c = fromLedger === null || fromLedger === void 0 ? void 0 : fromLedger.yieldEarned) !== null && _c !== void 0 ? _c : 0) +
711
+ parseFloat(balance) -
712
+ ledgerBalance,
713
+ });
714
+ }
715
+ return {
716
+ chainBalance,
717
+ lifetime,
718
+ tranches: reconciled,
719
+ mismatch: {
720
+ kind: phantom ? 'ledger-exceeds-chain' : 'chain-exceeds-ledger',
721
+ ledgerBalance: String(ledgerTotal),
722
+ chainBalance: String(chainTotal),
723
+ },
724
+ };
725
+ });
726
+ }
727
+ /**
728
+ * The last-epoch figures of positions whose newest interest is OLDER than
729
+ * the recent window. Pools clear independently, and some clear only when
730
+ * the originator repays, so a pool's last cycle can be weeks back: it is
731
+ * priced at THAT epoch, from a history read of just those positions over
732
+ * just those epochs, through the same `positionEpochYield` — so it equals
733
+ * the history's newest bar.
734
+ *
735
+ * A lender who has since left the tranche (no ledger shares now) gets no
736
+ * figure: an old pool's last cycle is not theirs to show. Costs nothing
737
+ * when no position qualifies.
738
+ */
739
+ staleLastEpochYields(positions, recentEpoch) {
740
+ return __awaiter(this, void 0, void 0, function* () {
741
+ var _a;
742
+ const stale = positions.flatMap(({ poolId, position }) => {
743
+ var _a;
744
+ const interest = (_a = position.tranche.lendingPoolTrancheEpochInterest) === null || _a === void 0 ? void 0 : _a[0];
745
+ if ((interest === null || interest === void 0 ? void 0 : interest.epochId) === undefined)
746
+ return [];
747
+ if (!(parseFloat(position.shares) > 0))
748
+ return [];
749
+ const epoch = Number(interest.epochId);
750
+ return epoch < recentEpoch
751
+ ? [{ poolId, position, epoch, interest }]
752
+ : [];
753
+ });
754
+ const yields = new Map();
755
+ if (stale.length === 0)
756
+ return yields;
757
+ const lockId = (poolId, lock) => { var _a; return (_a = lock.id) !== null && _a !== void 0 ? _a : `${poolId.toLowerCase()}-${lock.lockId}`; };
758
+ const { rows } = yield fetchYieldHistorySeries((variables) => this._graph.request(yieldHistoryPageQuery, variables), {
759
+ pools: [...new Set(stale.map(({ poolId }) => poolId))].map((id) => id.toLowerCase()),
760
+ tranches: [
761
+ ...new Set(stale.map(({ position }) => position.tranche.id)),
762
+ ],
763
+ userTranches: stale.map(({ position }) => position.id),
764
+ locks: stale.flatMap(({ poolId, position }) => position.userLendingPoolTrancheFixedTermDepositLocks.map((lock) => lockId(poolId, lock))),
765
+ }, {
766
+ fromEpoch: Math.min(...stale.map(({ epoch }) => epoch)),
767
+ toEpoch: Math.max(...stale.map(({ epoch }) => epoch)),
768
+ });
769
+ const starts = clearingStarts(rows.clearings);
770
+ for (const { poolId, position, epoch, interest } of stale) {
771
+ const series = positionSeriesFrom(rows, Object.assign(Object.assign({}, position), { userLendingPoolTrancheFixedTermDepositLocks: position.userLendingPoolTrancheFixedTermDepositLocks.map((lock) => (Object.assign(Object.assign({}, lock), { id: lockId(poolId, lock) }))) }));
772
+ yields.set(position.id, positionEpochYield(series, epoch, parseFloat(interest.epochInterestAmount), (_a = starts.get(poolId.toLowerCase())) === null || _a === void 0 ? void 0 : _a.get(epoch)).yield);
773
+ }
774
+ return yields;
775
+ });
776
+ }
500
777
  /**
501
778
  * A money figure as a plain decimal string. Below a millionth of a token
502
779
  * (the stable asset's own precision) is float noise, never money, so it