@kasufinance/kasu-sdk 2.13.1 → 2.14.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.
@@ -2,7 +2,10 @@ import { PoolTvlHistoryEntry } from '../domain/pool-tvl';
2
2
  import { selectVisiblePools } from '../domain/pools';
3
3
  import { pickShortDescription } from '../domain/strategy-summary';
4
4
  import { DataService } from '../services/DataService/data-service';
5
- import { PoolTvlHistoryOptions } from '../services/DataService/tvl-history';
5
+ import {
6
+ PoolTvlHistoryOptions,
7
+ PoolTvlHistoryResult,
8
+ } from '../services/DataService/tvl-history';
6
9
  import {
7
10
  PoolDelegateProfileAndHistory,
8
11
  PoolOverview,
@@ -175,6 +178,28 @@ export class StrategiesFacade {
175
178
  return await this._dataService.getPoolTvlHistory(poolId, options);
176
179
  }
177
180
 
181
+ /**
182
+ * `getPoolTvlHistory` plus `opening` — the newest clearing before the
183
+ * window, read at its own block like every other point, or `null` when
184
+ * the pool has none — from the same read. Pass the result to
185
+ * `combineTvlHistories` as a leg with its `usdRate`: the opening is what
186
+ * the leg held before its first point, so a window cut at a different
187
+ * time on another chain does not count it 0. Same options and errors as
188
+ * `getPoolTvlHistory`; the extra cost is one clearing row (or, for
189
+ * `fromEpoch`, one small request) and one block.
190
+ *
191
+ * ```ts
192
+ * const leg = await kasu.strategies.getPoolTvlHistoryWindow(poolId, { count: 52 });
193
+ * // { entries: PoolTvlHistoryEntry[], opening: PoolTvlHistoryEntry | null }
194
+ * ```
195
+ */
196
+ async getPoolTvlHistoryWindow(
197
+ poolId: string,
198
+ options: PoolTvlHistoryOptions = {},
199
+ ): Promise<PoolTvlHistoryResult> {
200
+ return await this._dataService.getPoolTvlHistoryWindow(poolId, options);
201
+ }
202
+
178
203
  /**
179
204
  * Fetch a single strategy by pool ID. Returns `null` if not found.
180
205
  */
@@ -0,0 +1,161 @@
1
+ import { CHAIN_CONFIGS } from './chain-configs';
2
+ import { ChainConfigEntry, SupportedChain } from './types';
3
+
4
+ /**
5
+ * Strategy ↔ deployment resolution. A lending strategy is not tied to one
6
+ * deployment: Tax Pay runs as one pool on Base (USDC) and another on XDC
7
+ * (AUDD), and the two share one CMS record keyed by the BASE pool id — the
8
+ * canonical id. Each chain config's `poolMetadataMapping` maps its
9
+ * chain-local ids to canonical ones; Base, being canonical, has none.
10
+ *
11
+ * Pure: it reads only the built-in `CHAIN_CONFIGS`. The same rules as
12
+ * kasu-ui's counterpart lookup, so both apps sum a strategy over the same
13
+ * pools.
14
+ */
15
+
16
+ /** The pool that represents a strategy on one deployment. */
17
+ export interface StrategyDeployment {
18
+ chain: SupportedChain;
19
+ /**
20
+ * Chain-local pool id, lowercase — what that chain's subgraph is keyed
21
+ * by.
22
+ */
23
+ poolId: string;
24
+ }
25
+
26
+ /**
27
+ * What `strategyDeployments` refuses, as a code:
28
+ *
29
+ * - `pool-id` — not a `0x` address;
30
+ * - `chain` — not a `SupportedChain`;
31
+ * - `wrong-chain` — the mapping places the id on ANOTHER deployment (an XDC
32
+ * chain-local id passed as Base, or a Base id passed as XDC). Querying one
33
+ * chain with another's address reads nothing, silently.
34
+ */
35
+ export type StrategyDeploymentInputErrorCode =
36
+ | 'pool-id'
37
+ | 'chain'
38
+ | 'wrong-chain';
39
+
40
+ /** What `strategyDeployments` throws for an id it cannot place. */
41
+ export class StrategyDeploymentInputError extends Error {
42
+ readonly code: StrategyDeploymentInputErrorCode;
43
+
44
+ constructor(code: StrategyDeploymentInputErrorCode) {
45
+ super(`strategyDeployments: input refused (${code})`);
46
+ this.name = 'StrategyDeploymentInputError';
47
+ this.code = code;
48
+ Object.setPrototypeOf(this, StrategyDeploymentInputError.prototype);
49
+ }
50
+ }
51
+
52
+ /** The canonical deployment: its pool ids ARE the strategies' ids. */
53
+ const CANONICAL_CHAIN: SupportedChain = 'base';
54
+
55
+ const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
56
+
57
+ type MappingConfigs = Readonly<
58
+ Record<SupportedChain, Pick<ChainConfigEntry, 'poolMetadataMapping'>>
59
+ >;
60
+
61
+ /** The deployment's mapping with both sides lowercased; Base never has one. */
62
+ function mappingOf(
63
+ configs: MappingConfigs,
64
+ chain: SupportedChain,
65
+ ): [string, string][] {
66
+ if (chain === CANONICAL_CHAIN) return [];
67
+ return Object.entries(configs[chain].poolMetadataMapping ?? {}).map(
68
+ ([local, canonical]) => [local.toLowerCase(), canonical.toLowerCase()],
69
+ );
70
+ }
71
+
72
+ /**
73
+ * `strategyDeployments` over any configs — the export reads `CHAIN_CONFIGS`;
74
+ * the spec passes its own to cover shapes the built-in mapping lacks.
75
+ */
76
+ export function resolveStrategyDeployments(
77
+ configs: MappingConfigs,
78
+ poolId: string,
79
+ chain: SupportedChain,
80
+ ): StrategyDeployment[] {
81
+ const id = typeof poolId === 'string' ? poolId.trim().toLowerCase() : '';
82
+ if (!ADDRESS_RE.test(id)) {
83
+ throw new StrategyDeploymentInputError('pool-id');
84
+ }
85
+ if (
86
+ typeof chain !== 'string' ||
87
+ !Object.prototype.hasOwnProperty.call(configs, chain)
88
+ ) {
89
+ throw new StrategyDeploymentInputError('chain');
90
+ }
91
+ const chains = Object.keys(configs) as SupportedChain[];
92
+ const mappings = chains.map((c) => [c, mappingOf(configs, c)] as const);
93
+ const isLocalElsewhere = mappings.some(
94
+ ([c, mapping]) =>
95
+ c !== chain && mapping.some(([local]) => local === id),
96
+ );
97
+
98
+ let canonical: string;
99
+ if (chain === CANONICAL_CHAIN) {
100
+ // A Base id is its own Base pool — unless the mapping says it is
101
+ // another deployment's chain-local id, which names no Base pool.
102
+ if (isLocalElsewhere) {
103
+ throw new StrategyDeploymentInputError('wrong-chain');
104
+ }
105
+ canonical = id;
106
+ } else {
107
+ const own = mappingOf(configs, chain).find(([local]) => local === id);
108
+ if (own) {
109
+ canonical = own[1];
110
+ } else {
111
+ const isCanonical = mappings.some(([, mapping]) =>
112
+ mapping.some(([, target]) => target === id),
113
+ );
114
+ if (isLocalElsewhere || isCanonical) {
115
+ throw new StrategyDeploymentInputError('wrong-chain');
116
+ }
117
+ // Unmapped off Base: a strategy of one on its own chain (every
118
+ // XDC USDC pool today). Claiming a Base counterpart would read
119
+ // Base with an XDC address.
120
+ return [{ chain, poolId: id }];
121
+ }
122
+ }
123
+
124
+ const found: StrategyDeployment[] = [
125
+ { chain: CANONICAL_CHAIN, poolId: canonical },
126
+ ];
127
+ for (const [c, mapping] of mappings) {
128
+ for (const [local, target] of mapping) {
129
+ if (target === canonical) found.push({ chain: c, poolId: local });
130
+ }
131
+ }
132
+ return found;
133
+ }
134
+
135
+ /**
136
+ * Every deployment a strategy has a pool on, canonical (Base) first, then the
137
+ * others in `CHAIN_CONFIGS` order — the legs to fetch and pass to
138
+ * `combineTvlHistories`. One entry for a strategy that lives on one chain
139
+ * only (PayFi, on Base).
140
+ *
141
+ * `chain` is the deployment `poolId` was read on — required, because an
142
+ * address alone does not say which chain it belongs to. From either side of
143
+ * a mapping the answer is the same set: Tax Pay's Base id on `'base'` and
144
+ * its XDC id on `'xdc'` both return both pools. An id the mapping does not
145
+ * know is a strategy of one on its own chain. Ids are compared
146
+ * case-insensitively and returned lowercase.
147
+ *
148
+ * Throws `StrategyDeploymentInputError` for a malformed id, an unknown chain,
149
+ * or an id the mapping places on another deployment (`'wrong-chain'`).
150
+ *
151
+ * ```ts
152
+ * strategyDeployments(strategy.id, 'base');
153
+ * // [{ chain: 'base', poolId: '0x…' }, { chain: 'xdc', poolId: '0x…' }]
154
+ * ```
155
+ */
156
+ export function strategyDeployments(
157
+ poolId: string,
158
+ chain: SupportedChain,
159
+ ): StrategyDeployment[] {
160
+ return resolveStrategyDeployments(CHAIN_CONFIGS, poolId, chain);
161
+ }
package/src/index.ts CHANGED
@@ -45,7 +45,10 @@ export type {
45
45
 
46
46
  export type { YieldHistoryOptions } from './services/Portfolio/yield-history';
47
47
 
48
- export type { PoolTvlHistoryOptions } from './services/DataService/tvl-history';
48
+ export type {
49
+ PoolTvlHistoryOptions,
50
+ PoolTvlHistoryResult,
51
+ } from './services/DataService/tvl-history';
49
52
  export {
50
53
  PastStateUnavailableError,
51
54
  PoolTvlHistoryAbortedError,
@@ -53,13 +53,18 @@ import {
53
53
  ClearingsScope,
54
54
  createOffChainTvlReader,
55
55
  distinctBlocks,
56
+ fetchClearingBefore,
57
+ fetchClearingBeforeBlock,
56
58
  fetchOffChainTvlAtBlocks,
57
59
  fetchPoolBalancesAtBlocks,
58
60
  fetchPoolClearings,
59
61
  GraphRequest,
62
+ needsClearingBeforeBlock,
60
63
  OffChainTvlReader,
61
64
  PastBlockCache,
65
+ pickOpeningClearing,
62
66
  PoolTvlHistoryOptions,
67
+ PoolTvlHistoryResult,
63
68
  resolvePoolTvlHistoryWindow,
64
69
  runCancellable,
65
70
  TVL_HISTORY_CACHE_DEPTH,
@@ -294,6 +299,47 @@ export class DataService {
294
299
  poolId: string,
295
300
  options: PoolTvlHistoryOptions = {},
296
301
  ): Promise<PoolTvlHistoryEntry[]> {
302
+ return (await this._poolTvlHistory(poolId, options, false)).entries;
303
+ }
304
+
305
+ /**
306
+ * `getPoolTvlHistory` — the same options, errors and `entries` — plus
307
+ * `opening`: the pool's value just before the window, so a strategy
308
+ * summed across deployments (`combineTvlHistories`) knows what each leg
309
+ * held before its first point instead of counting it 0.
310
+ *
311
+ * `opening` is the newest finished clearing STRICTLY before the window's
312
+ * first block (by `pickOpeningClearing`), read at its own block exactly
313
+ * like every other point and through the same past-block cache; `null`
314
+ * when the pool has no clearing before the window. Extra cost over
315
+ * `getPoolTvlHistory`:
316
+ *
317
+ * - `count` only: one more clearing row in the same request (`count + 1`,
318
+ * which always reaches past the window when older history exists);
319
+ * - `fromEpoch` above 0: one more one-row subgraph request, in parallel
320
+ * with the clearings;
321
+ * - only when every left-out clearing finished in the window's first
322
+ * block (a catch-up block that closed several epochs at once): one
323
+ * more one-row request by block (`fetchClearingBeforeBlock`), after
324
+ * the clearings — the common case never makes it;
325
+ * - either way, the opening's block in the same batched time-travel read
326
+ * and one more `externalTVLOfPool` read (cached once final).
327
+ *
328
+ * `{ entries: [], opening: null }` wherever `getPoolTvlHistory` returns
329
+ * `[]`.
330
+ */
331
+ async getPoolTvlHistoryWindow(
332
+ poolId: string,
333
+ options: PoolTvlHistoryOptions = {},
334
+ ): Promise<PoolTvlHistoryResult> {
335
+ return await this._poolTvlHistory(poolId, options, true);
336
+ }
337
+
338
+ private async _poolTvlHistory(
339
+ poolId: string,
340
+ options: PoolTvlHistoryOptions,
341
+ withOpening: boolean,
342
+ ): Promise<PoolTvlHistoryResult> {
297
343
  validatePoolTvlHistoryOptions(options);
298
344
  if (!isAddress(poolId)) {
299
345
  throw new TypeError(
@@ -324,6 +370,7 @@ export class DataService {
324
370
  : null,
325
371
  externalTvl ?? '',
326
372
  cancellation,
373
+ withOpening,
327
374
  ),
328
375
  );
329
376
  }
@@ -334,7 +381,9 @@ export class DataService {
334
381
  reader: OffChainTvlReader | null,
335
382
  externalTvl: string,
336
383
  cancellation: Cancellation,
337
- ): Promise<PoolTvlHistoryEntry[]> {
384
+ withOpening: boolean,
385
+ ): Promise<PoolTvlHistoryResult> {
386
+ const nothing: PoolTvlHistoryResult = { entries: [], opening: null };
338
387
  const graph: GraphRequest = <T>(
339
388
  document: string,
340
389
  variables: Record<string, unknown>,
@@ -343,20 +392,32 @@ export class DataService {
343
392
  this._graph.request<T>({ document, variables, signal });
344
393
  const scope: ClearingsScope =
345
394
  options.fromEpoch === undefined && options.count !== undefined
346
- ? { kind: 'newest', count: options.count }
395
+ ? {
396
+ kind: 'newest',
397
+ // One past the window: the opening, when there is one.
398
+ count: options.count + (withOpening ? 1 : 0),
399
+ }
347
400
  : { kind: 'from', fromEpoch: options.fromEpoch ?? 0 };
348
401
 
349
402
  try {
350
- const [currentEpochNumber, { clearings, snapshot }] =
403
+ const [currentEpochNumber, { clearings, snapshot }, before] =
351
404
  await Promise.all([
352
405
  this._systemVariablesAbi.currentEpochNumber(),
353
406
  fetchPoolClearings(graph, pool, scope, cancellation),
407
+ withOpening && scope.kind === 'from' && scope.fromEpoch > 0
408
+ ? fetchClearingBefore(
409
+ graph,
410
+ pool,
411
+ scope.fromEpoch,
412
+ cancellation,
413
+ )
414
+ : Promise.resolve(null),
354
415
  ]);
355
416
  const currentEpoch = currentEpochNumber.toNumber();
356
417
 
357
418
  const window = resolvePoolTvlHistoryWindow(options, currentEpoch);
358
419
  const poolNow = snapshot.pool;
359
- if (!window || !poolNow) return [];
420
+ if (!window || !poolNow) return nothing;
360
421
  const head = snapshot.meta?.block;
361
422
  if (!head || typeof head.timestamp !== 'number') {
362
423
  throw new Error(
@@ -369,7 +430,30 @@ export class DataService {
369
430
  c.epochId >= window.fromEpoch &&
370
431
  c.epochId <= window.toEpoch,
371
432
  );
372
- const blocks = distinctBlocks(inWindow);
433
+ // Clearings the window left out, older by epoch: the extra row of
434
+ // a count fetch, or the one-row read below `fromEpoch`.
435
+ const older =
436
+ scope.kind === 'newest'
437
+ ? clearings.filter((c) => c.epochId < window.fromEpoch)
438
+ : before === null
439
+ ? []
440
+ : [before];
441
+ let opening = withOpening
442
+ ? pickOpeningClearing(older, inWindow)
443
+ : null;
444
+ // The left-out clearings all sat in the first in-window block (a
445
+ // catch-up block): ask for the newest one before it, by block.
446
+ if (withOpening && needsClearingBeforeBlock(older, opening)) {
447
+ opening = await fetchClearingBeforeBlock(
448
+ graph,
449
+ pool,
450
+ Math.min(...inWindow.map((c) => c.block)),
451
+ cancellation,
452
+ );
453
+ }
454
+ const blocks = distinctBlocks(
455
+ opening ? [...inWindow, opening] : inWindow,
456
+ );
373
457
  const withCurrent = window.toEpoch >= currentEpoch;
374
458
  const cacheable = (block: number): boolean =>
375
459
  block <= head.number - TVL_HISTORY_CACHE_DEPTH;
@@ -393,7 +477,7 @@ export class DataService {
393
477
  },
394
478
  ]);
395
479
 
396
- return buildPoolTvlHistory({
480
+ const entries = buildPoolTvlHistory({
397
481
  clearings: inWindow,
398
482
  balances,
399
483
  offChain: offChainRead.offChain,
@@ -407,9 +491,21 @@ export class DataService {
407
491
  : null,
408
492
  decimals: this._kasuConfig.stableAssetDecimals,
409
493
  });
494
+ // The same function as every other point, so the opening cannot
495
+ // be computed another way.
496
+ const [openingEntry] = opening
497
+ ? buildPoolTvlHistory({
498
+ clearings: [opening],
499
+ balances,
500
+ offChain: offChainRead.offChain,
501
+ current: null,
502
+ decimals: this._kasuConfig.stableAssetDecimals,
503
+ })
504
+ : [null];
505
+ return { entries, opening: openingEntry };
410
506
  } catch (error) {
411
507
  if (isEmptyHistoryError(error, this._kasuConfig.isLiteDeployment)) {
412
- return [];
508
+ return nothing;
413
509
  }
414
510
  throw error;
415
511
  }
@@ -37,6 +37,22 @@ export interface PoolTvlHistoryOptions {
37
37
  signal?: AbortSignal;
38
38
  }
39
39
 
40
+ /**
41
+ * `getPoolTvlHistoryWindow`'s answer: the same entries `getPoolTvlHistory`
42
+ * returns, and the pool's value just before them. Pass it to
43
+ * `combineTvlHistories` as a leg (`{ ...result, usdRate }`).
44
+ */
45
+ export interface PoolTvlHistoryResult {
46
+ entries: PoolTvlHistoryEntry[];
47
+ /**
48
+ * The newest finished clearing BEFORE the window, in a block strictly
49
+ * before the first entry's, read at its own block like every other point
50
+ * (`kind: 'clearing'`). `null` when the pool has no clearing before the
51
+ * window — its whole history is in `entries`. See `pickOpeningClearing`.
52
+ */
53
+ opening: PoolTvlHistoryEntry | null;
54
+ }
55
+
40
56
  /** The resolved, inclusive epoch range; `null` when it is empty. */
41
57
  export interface PoolTvlHistoryWindow {
42
58
  fromEpoch: number;
@@ -449,6 +465,121 @@ export async function fetchPoolClearings(
449
465
  return { clearings, snapshot, requests };
450
466
  }
451
467
 
468
+ /**
469
+ * Pure: the opening clearing — the newest of `before` (finished clearings the
470
+ * window left out, older by epoch) that finished STRICTLY before the first
471
+ * in-window clearing's block. Newest by block, then epoch, so a clearing
472
+ * numbered out of order (XDC AUDD's epoch 1, a week before epoch 88) is
473
+ * placed by when it happened. A clearing in the first in-window block itself
474
+ * (a catch-up block that finished several epochs at once) is not an opening:
475
+ * it holds the first entry's own value, not the one before it. `null` when
476
+ * there is none — see `needsClearingBeforeBlock`.
477
+ */
478
+ export function pickOpeningClearing(
479
+ before: readonly Clearing[],
480
+ inWindow: readonly Clearing[],
481
+ ): Clearing | null {
482
+ const firstBlock = Math.min(...inWindow.map((c) => c.block));
483
+ let opening: Clearing | null = null;
484
+ for (const clearing of before) {
485
+ if (clearing.block >= firstBlock) continue;
486
+ if (
487
+ opening === null ||
488
+ clearing.block > opening.block ||
489
+ (clearing.block === opening.block &&
490
+ clearing.epochId > opening.epochId)
491
+ ) {
492
+ opening = clearing;
493
+ }
494
+ }
495
+ return opening;
496
+ }
497
+
498
+ /**
499
+ * Pure: whether the opening needs `fetchClearingBefore`'s by-block read.
500
+ * Only when the window left clearings out but every one of them finished in
501
+ * or after the first in-window block — the extra rows sat in a catch-up
502
+ * block — so an older one, if any, was not fetched. Otherwise the opening
503
+ * (or its absence) is already known and nothing more is read.
504
+ */
505
+ export function needsClearingBeforeBlock(
506
+ before: readonly Clearing[],
507
+ opening: Clearing | null,
508
+ ): boolean {
509
+ return opening === null && before.length > 0;
510
+ }
511
+
512
+ /**
513
+ * The newest finished clearing in a block below `blockLt`, by block: the
514
+ * opening when every clearing the window left out shares its first block.
515
+ * ONE extra request for one row, no snapshot. Clearings that share that
516
+ * older block share its time and balance, so which one comes back does not
517
+ * change the value.
518
+ */
519
+ export const poolTvlClearingBeforeBlockQuery = gql`
520
+ query PoolTvlClearingBeforeBlock($pool: String!, $blockLt: BigInt!) {
521
+ clearings: lendingPoolClearings(
522
+ first: 1
523
+ orderBy: endBlockNumber
524
+ orderDirection: desc
525
+ where: {
526
+ lendingPool: $pool
527
+ isFinished: true
528
+ endBlockNumber_lt: $blockLt
529
+ }
530
+ ) {
531
+ epochId
532
+ endBlockNumber
533
+ endTimestamp
534
+ }
535
+ }
536
+ `;
537
+
538
+ export async function fetchClearingBeforeBlock(
539
+ request: GraphRequest,
540
+ poolId: string,
541
+ blockLt: number,
542
+ cancellation: Cancellation,
543
+ ): Promise<Clearing | null> {
544
+ cancellation.throwIfCancelled();
545
+ const page = await request<{ clearings: ClearingRow[] }>(
546
+ poolTvlClearingBeforeBlockQuery,
547
+ { pool: poolId, blockLt: String(blockLt) },
548
+ cancellation.signal,
549
+ );
550
+ const row = page.clearings.at(0);
551
+ return row === undefined ? null : parseClearing(row);
552
+ }
553
+
554
+ /**
555
+ * The newest finished clearing numbered below `beforeEpoch`: the opening
556
+ * candidate of a `fromEpoch` window, whose own fetch never reads that far
557
+ * back. ONE extra request for one row, no snapshot.
558
+ */
559
+ export async function fetchClearingBefore(
560
+ request: GraphRequest,
561
+ poolId: string,
562
+ beforeEpoch: number,
563
+ cancellation: Cancellation,
564
+ ): Promise<Clearing | null> {
565
+ cancellation.throwIfCancelled();
566
+ const page = await request<PoolTvlClearingsResult>(
567
+ poolTvlClearingsQuery,
568
+ {
569
+ poolId,
570
+ pool: poolId,
571
+ epochGte: '0',
572
+ epochLt: String(beforeEpoch),
573
+ direction: 'desc',
574
+ first: 1,
575
+ withSnapshot: false,
576
+ },
577
+ cancellation.signal,
578
+ );
579
+ const row = page.clearings.at(0);
580
+ return row === undefined ? null : parseClearing(row);
581
+ }
582
+
452
583
  /**
453
584
  * The pool's balance (display units, the subgraph's decimal string) at the end
454
585
  * of each block, `TVL_HISTORY_BLOCKS_PER_REQUEST` blocks per request. A block