@kasufinance/kasu-sdk 2.13.0 → 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.
@@ -64,3 +64,284 @@ export interface PoolTvlHistoryEntry {
64
64
  /** `'clearing'` for a cycle boundary; `'current'` for the closing point. */
65
65
  kind: 'clearing' | 'current';
66
66
  }
67
+
68
+ /**
69
+ * One deployment's share of a strategy's history, for `combineTvlHistories`:
70
+ * that pool's `getPoolTvlHistoryWindow` result (`{ entries, opening }`) as it
71
+ * came back, and what one unit of the deployment's stable token is worth in
72
+ * USD (`1` for USDC; today's AUD→USD for AUDD). The SDK fetches no exchange
73
+ * rate: the caller passes it.
74
+ */
75
+ export interface TvlHistoryLeg {
76
+ /** At least one point: a real pool always returns its `current` point. */
77
+ entries: readonly PoolTvlHistoryEntry[];
78
+ /** USD per unit of the leg's stable token. Finite and above 0. */
79
+ usdRate: number;
80
+ /**
81
+ * The leg's step BEFORE its first entry: the newest clearing before the
82
+ * window, as `getPoolTvlHistoryWindow` read it at its own block. It
83
+ * counts from its own `timestamp` on, and the leg is 0 before that — no
84
+ * value is carried back to a time before it existed. A `'clearing'`
85
+ * point no later than the first entry; it is never drawn. `null` or
86
+ * absent: the pool has no clearing before the window (it started lending
87
+ * later, say) and counts 0 before its first entry.
88
+ */
89
+ opening?: PoolTvlHistoryEntry | null;
90
+ }
91
+
92
+ /**
93
+ * One point of a strategy's TVL summed across its deployments.
94
+ *
95
+ * There is no `epochId`: each chain clears at its own blocks and times, so a
96
+ * merged point belongs to no single epoch. Plot by `timestamp`.
97
+ */
98
+ export interface StrategyTvlHistoryEntry {
99
+ /** Unix SECONDS. Strictly ascending across the series. */
100
+ timestamp: number;
101
+ /** The sum over legs of each leg's TVL then, times its `usdRate`. */
102
+ tvlUsd: number;
103
+ /** `'clearing'` for a cycle boundary on any leg; `'current'` for the closing point. */
104
+ kind: 'clearing' | 'current';
105
+ }
106
+
107
+ /**
108
+ * What `combineTvlHistories` refuses, as a code:
109
+ *
110
+ * - `legs` — not a non-empty array (or one with holes);
111
+ * - `leg` — a leg that is not an object;
112
+ * - `usd-rate` — a rate that is not a finite number above 0;
113
+ * - `opening` — an opening that is not a well-formed `'clearing'` point at
114
+ * or before the leg's first entry (or `null`, or absent);
115
+ * - `entries` — a leg's `entries` is not an array;
116
+ * - `empty-leg` — a leg with no entries: pass only the legs you have;
117
+ * - `entry` — an entry that is not an object (or a hole);
118
+ * - `timestamp` / `tvl` — not a finite number at or above 0;
119
+ * - `kind` — neither `'clearing'` nor `'current'`;
120
+ * - `order` — timestamps going backwards within a leg;
121
+ * - `current` — a `current` point that is not the leg's last, or some legs
122
+ * ending in one and others not;
123
+ * - `overflow` — a sum too large to be a finite number.
124
+ */
125
+ export type TvlHistoryInputErrorCode =
126
+ | 'legs'
127
+ | 'leg'
128
+ | 'usd-rate'
129
+ | 'opening'
130
+ | 'entries'
131
+ | 'empty-leg'
132
+ | 'entry'
133
+ | 'timestamp'
134
+ | 'tvl'
135
+ | 'kind'
136
+ | 'order'
137
+ | 'current'
138
+ | 'overflow';
139
+
140
+ /**
141
+ * What `combineTvlHistories` throws instead of returning a number it cannot
142
+ * stand behind. `leg` and `entry` are the zero-based position of the
143
+ * offending input, when there is one.
144
+ */
145
+ export class TvlHistoryInputError extends Error {
146
+ readonly code: TvlHistoryInputErrorCode;
147
+ readonly leg: number | undefined;
148
+ readonly entry: number | undefined;
149
+
150
+ constructor(code: TvlHistoryInputErrorCode, leg?: number, entry?: number) {
151
+ const at = [
152
+ leg === undefined ? '' : ` at leg ${String(leg)}`,
153
+ entry === undefined ? '' : `, entry ${String(entry)}`,
154
+ ].join('');
155
+ super(`combineTvlHistories: input refused (${code})${at}`);
156
+ this.name = 'TvlHistoryInputError';
157
+ this.code = code;
158
+ this.leg = leg;
159
+ this.entry = entry;
160
+ Object.setPrototypeOf(this, TvlHistoryInputError.prototype);
161
+ }
162
+ }
163
+
164
+ function isNonNegativeFinite(value: unknown): value is number {
165
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
166
+ }
167
+
168
+ /**
169
+ * Throws unless the leg is well formed. Index loops, not `forEach`: a hole in
170
+ * a sparse array is skipped by the array methods and would surface later as
171
+ * a raw `TypeError` instead of a refusal.
172
+ */
173
+ function validateLeg(leg: unknown, legIndex: number): TvlHistoryLeg {
174
+ if (typeof leg !== 'object' || leg === null) {
175
+ throw new TvlHistoryInputError('leg', legIndex);
176
+ }
177
+ const { entries, usdRate, opening } = leg as Partial<TvlHistoryLeg>;
178
+ if (
179
+ typeof usdRate !== 'number' ||
180
+ !Number.isFinite(usdRate) ||
181
+ usdRate <= 0
182
+ ) {
183
+ throw new TvlHistoryInputError('usd-rate', legIndex);
184
+ }
185
+ if (!Array.isArray(entries as unknown)) {
186
+ throw new TvlHistoryInputError('entries', legIndex);
187
+ }
188
+ const list = entries as readonly unknown[];
189
+ if (list.length === 0) {
190
+ throw new TvlHistoryInputError('empty-leg', legIndex);
191
+ }
192
+ let previous = -Infinity;
193
+ for (let index = 0; index < list.length; index += 1) {
194
+ const entry = list[index];
195
+ if (typeof entry !== 'object' || entry === null) {
196
+ throw new TvlHistoryInputError('entry', legIndex, index);
197
+ }
198
+ const { timestamp, tvl, kind } = entry as Partial<PoolTvlHistoryEntry>;
199
+ if (!isNonNegativeFinite(timestamp)) {
200
+ throw new TvlHistoryInputError('timestamp', legIndex, index);
201
+ }
202
+ if (!isNonNegativeFinite(tvl)) {
203
+ throw new TvlHistoryInputError('tvl', legIndex, index);
204
+ }
205
+ if (kind !== 'clearing' && kind !== 'current') {
206
+ throw new TvlHistoryInputError('kind', legIndex, index);
207
+ }
208
+ if (kind === 'current' && index !== list.length - 1) {
209
+ throw new TvlHistoryInputError('current', legIndex, index);
210
+ }
211
+ if (timestamp < previous) {
212
+ throw new TvlHistoryInputError('order', legIndex, index);
213
+ }
214
+ previous = timestamp;
215
+ }
216
+ if (opening !== undefined && opening !== null) {
217
+ const first = list[0] as PoolTvlHistoryEntry;
218
+ const { timestamp, tvl, kind } = (
219
+ typeof opening === 'object' ? opening : {}
220
+ ) as Partial<PoolTvlHistoryEntry>;
221
+ if (
222
+ !isNonNegativeFinite(timestamp) ||
223
+ !isNonNegativeFinite(tvl) ||
224
+ kind !== 'clearing' ||
225
+ timestamp > first.timestamp
226
+ ) {
227
+ throw new TvlHistoryInputError('opening', legIndex);
228
+ }
229
+ }
230
+ return leg as TvlHistoryLeg;
231
+ }
232
+
233
+ /**
234
+ * A strategy's TVL in USD over time, from its deployments' histories — Tax
235
+ * Pay runs on Base (USDC) and on XDC (AUDD), and its total lent is both.
236
+ * Pure: the caller fetches each leg (`strategyDeployments` names them) and
237
+ * passes the exchange rates.
238
+ *
239
+ * A step merge by time. At every distinct timestamp where any leg has a
240
+ * clearing, the value is the sum over legs of that leg's latest point at or
241
+ * before then, times its `usdRate`. A leg's `opening` — what it held just
242
+ * before the window — is one more step: it counts from its own timestamp
243
+ * until the leg's first entry, and the leg is 0 before it (or throughout,
244
+ * before its first entry, without one).
245
+ * Points that share a timestamp — clearings run in one block, or two chains
246
+ * clearing in the same second — are one output point. The series ends with
247
+ * one `current` point at the latest of the legs' `current` timestamps, equal
248
+ * to the sum of their `current` values. Every leg must end in a `current`
249
+ * point, or none may.
250
+ *
251
+ * The window is the union of the legs' windows: the series starts at the
252
+ * earliest first entry of any leg. An `opening` is a value, never a point.
253
+ * Without it, two windows cut at offset clearing times would count the
254
+ * later-starting leg 0 at the left edge and draw a false step up; with it,
255
+ * only a genuine late start (`opening: null`) counts 0.
256
+ *
257
+ * Every leg and entry is validated first: a bad input — an empty leg
258
+ * included — throws `TvlHistoryInputError` (its `code` says why), so the
259
+ * result never holds `NaN` or `Infinity`. The inputs are not modified.
260
+ *
261
+ * ```ts
262
+ * const window = { count: 52 };
263
+ * const points = combineTvlHistories([
264
+ * { ...(await base.strategies.getPoolTvlHistoryWindow(basePool, window)), usdRate: 1 },
265
+ * { ...(await xdc.strategies.getPoolTvlHistoryWindow(xdcPool, window)), usdRate: audToUsd },
266
+ * ]);
267
+ * ```
268
+ */
269
+ export function combineTvlHistories(
270
+ legs: readonly TvlHistoryLeg[],
271
+ ): StrategyTvlHistoryEntry[] {
272
+ if (!Array.isArray(legs as unknown) || legs.length === 0) {
273
+ throw new TvlHistoryInputError('legs');
274
+ }
275
+ const valid: TvlHistoryLeg[] = [];
276
+ for (let index = 0; index < legs.length; index += 1) {
277
+ valid.push(validateLeg(legs[index], index));
278
+ }
279
+ const endsInCurrent = valid.map(
280
+ (leg) => leg.entries[leg.entries.length - 1].kind === 'current',
281
+ );
282
+ const odd = endsInCurrent.findIndex((ends) => ends !== endsInCurrent[0]);
283
+ if (odd !== -1) throw new TvlHistoryInputError('current', odd);
284
+
285
+ const finalTimestamp = endsInCurrent[0]
286
+ ? Math.max(
287
+ ...valid.map(
288
+ (leg) => leg.entries[leg.entries.length - 1].timestamp,
289
+ ),
290
+ )
291
+ : undefined;
292
+ const clearingTimes = [
293
+ ...new Set(
294
+ valid.flatMap((leg) =>
295
+ leg.entries
296
+ .filter((entry) => entry.kind === 'clearing')
297
+ .map((entry) => entry.timestamp),
298
+ ),
299
+ ),
300
+ ]
301
+ .filter((t) => finalTimestamp === undefined || t < finalTimestamp)
302
+ .sort((a, b) => a - b);
303
+
304
+ // One cursor per leg: the times ascend, so each leg is walked once. A
305
+ // leg's `current` point is a step like any other, which keeps a chain
306
+ // whose subgraph lags behind the other's last clearing at its own latest.
307
+ const cursors = valid.map(() => -1);
308
+ const sumAt = (t: number): number => {
309
+ let sum = 0;
310
+ valid.forEach((leg, index) => {
311
+ let cursor = cursors[index];
312
+ while (
313
+ cursor + 1 < leg.entries.length &&
314
+ leg.entries[cursor + 1].timestamp <= t
315
+ ) {
316
+ cursor += 1;
317
+ }
318
+ cursors[index] = cursor;
319
+ // Before the first entry, the opening is the step — from its
320
+ // own time on, never earlier.
321
+ const opening = leg.opening;
322
+ const value =
323
+ cursor >= 0
324
+ ? leg.entries[cursor].tvl
325
+ : opening && opening.timestamp <= t
326
+ ? opening.tvl
327
+ : 0;
328
+ sum += value * leg.usdRate;
329
+ });
330
+ if (!Number.isFinite(sum)) throw new TvlHistoryInputError('overflow');
331
+ return sum;
332
+ };
333
+
334
+ const combined: StrategyTvlHistoryEntry[] = clearingTimes.map((t) => ({
335
+ timestamp: t,
336
+ tvlUsd: sumAt(t),
337
+ kind: 'clearing' as const,
338
+ }));
339
+ if (finalTimestamp !== undefined) {
340
+ combined.push({
341
+ timestamp: finalTimestamp,
342
+ tvlUsd: sumAt(finalTimestamp),
343
+ kind: 'current',
344
+ });
345
+ }
346
+ return combined;
347
+ }
@@ -82,10 +82,13 @@ export interface RequestState {
82
82
  statusCode: RequestStatusCode;
83
83
  kind: RequestKind;
84
84
  /**
85
- * The amount originally asked for, always positive. A cancelled request
86
- * has its on-request `requestedAmount` zeroed by the subgraph, so the
87
- * original is recovered from the `Initiated` event (see
88
- * `initiatedAmount`).
85
+ * `UserRequest.requestedAmount`, in assets: the amount still OUTSTANDING,
86
+ * which the subgraph reduces as parts are accepted or rejected (0 once a
87
+ * request is fully resolved). A cancelled request has it zeroed by the
88
+ * subgraph, so for a cancellation it is the `Cancelled` event's amount —
89
+ * what was outstanding when cancelled, top-ups included — falling back to
90
+ * the `Initiated` event (see `initiatedAmount`) only when that event
91
+ * carries none.
89
92
  */
90
93
  requestedAmount: number;
91
94
  /**
@@ -287,16 +290,24 @@ const initiatedEvent = (
287
290
  ): UserRequestEvent | undefined =>
288
291
  events.find((e) => e.requestType === 'Initiated');
289
292
 
293
+ /** `assetAmount` of the `Initiated` event, or 0 when there is none. */
294
+ const initiatedAmountOf = (events: UserRequestEvent[]): number => {
295
+ const initiated = initiatedEvent(events);
296
+ return initiated ? Number(initiated.assetAmount || '0') : 0;
297
+ };
298
+
290
299
  /**
291
300
  * Subgraph behaviour: when a request is cancelled, the on-request
292
301
  * `requestedAmount` field is reset to 0 (the lender's effective balance is
293
- * restored). The original amount survives on the `Initiated` event's
294
- * `assetAmount`. Recover from there so cancelled rows still carry the amount
295
- * the lender originally asked for.
302
+ * restored). The amount cancelled survives on the `Cancelled` event's
303
+ * `assetAmount` — everything still outstanding at the cancel, top-ups
304
+ * included. Only when that event carries no figure does this fall back to the
305
+ * `Initiated` event, which holds the FIRST submission alone and so misses
306
+ * every top-up.
296
307
  */
297
- const initiatedAmountOf = (events: UserRequestEvent[]): number => {
298
- const initiated = initiatedEvent(events);
299
- return initiated ? Number(initiated.assetAmount || '0') : 0;
308
+ const cancelledAmountOf = (events: UserRequestEvent[]): number => {
309
+ const cancelledEvent = events.find((e) => e.requestType === 'Cancelled');
310
+ return num(cancelledEvent?.assetAmount) || initiatedAmountOf(events);
300
311
  };
301
312
 
302
313
  /**
@@ -332,17 +343,15 @@ const numOrNull = (str: string | null | undefined): number | null => {
332
343
  * Convert a `UserRequest` into a `RequestState`. Pure — no clock, no network,
333
344
  * no copy.
334
345
  *
335
- * BRANCH ORDER (this IS the behaviour; it reproduces kasu-ui's
336
- * `deriveTransactionView` check for check):
346
+ * BRANCH ORDER (this IS the behaviour):
337
347
  *
338
348
  * 1. a `Cancelled` event → `cancelled`, neutral, 0
339
349
  * 2. a withdrawal with a `Forced` event → `forced`, outflow, −accepted
340
350
  * 3. a reallocated deposit → `reallocated`, inflow, +accepted
341
- * 4. a withdrawal partly filled with a LIVE
342
- * remainder (cycle still open) → `partial`, outflow, −accepted
351
+ * 4. a withdrawal partly filled (paid > 0) with
352
+ * a LIVE remainder (queued > 0, cycle open) → `partial`, outflow, −accepted
343
353
  * 5. resolved (`status === 'Processed'`):
344
- * withdrawal, partly filled → `partial`, outflow, −accepted
345
- * withdrawal, fully filled → `complete`, outflow, −accepted
354
+ * withdrawal (nothing left queued) → `complete`, outflow, −accepted
346
355
  * deposit, nothing accepted → `rejected`, neutral, 0
347
356
  * deposit, part rejected → `partial`, inflow, +accepted
348
357
  * deposit, fully accepted → `complete`, inflow, +accepted
@@ -369,10 +378,10 @@ export function deriveRequestState(request: UserRequest): RequestState {
369
378
  const isWithdrawal = request.requestType === 'Withdrawal';
370
379
  const cancelled = isCancelled(request.events);
371
380
  // Cancelled requests have `requestedAmount` zeroed on the request itself;
372
- // pull the original value from the Initiated event so the row still
373
- // carries "100 cancelled" instead of "0 cancelled".
381
+ // take the amount from the Cancelled event so the row still carries
382
+ // "1500 cancelled" (1000 + a 500 top-up) instead of "0 cancelled".
374
383
  const requested = cancelled
375
- ? num(request.requestedAmount) || initiatedAmountOf(request.events)
384
+ ? num(request.requestedAmount) || cancelledAmountOf(request.events)
376
385
  : num(request.requestedAmount);
377
386
  const accepted = num(request.acceptedAmount);
378
387
  const rejected = num(request.rejectedAmount);
@@ -441,11 +450,14 @@ export function deriveRequestState(request: UserRequest): RequestState {
441
450
 
442
451
  // 4. Withdrawal partly filled with a LIVE remainder: the request returns
443
452
  // to the queue with a live Cancel. Checked BEFORE the resolved branch and
444
- // via `isCycleClosed`, never via the sticky raw status.
453
+ // via `isCycleClosed`, never via the sticky raw status. `requested` is
454
+ // what is still OUTSTANDING, so "partly filled" is "something paid AND
455
+ // something still queued" — never a comparison of the two, which would
456
+ // turn on whether more was paid than is left.
445
457
  if (
446
458
  isWithdrawal &&
447
459
  accepted > 0 &&
448
- accepted < requested &&
460
+ requested > 0 &&
449
461
  !isCycleClosed(request)
450
462
  ) {
451
463
  return {
@@ -458,13 +470,15 @@ export function deriveRequestState(request: UserRequest): RequestState {
458
470
 
459
471
  // 5. Resolved — a rejection IS a resolution.
460
472
  if (request.status === UserRequestStatus.PROCESSED) {
473
+ // A withdrawal is resolved only once nothing is left queued — a
474
+ // partly filled one returns to the queue (branch 4), and one cancelled
475
+ // with a remainder is branch 1 — so a resolved withdrawal is complete.
461
476
  if (isWithdrawal) {
462
- const partly = accepted < requested;
463
477
  return {
464
478
  ...base,
465
479
  kind: 'outflow',
466
480
  amount: -accepted,
467
- statusCode: partly ? 'partial' : 'complete',
481
+ statusCode: 'complete',
468
482
  };
469
483
  }
470
484
 
@@ -19,6 +19,16 @@ export { READ_ONLY_MESSAGE } from './read-only';
19
19
  // Chain configurations
20
20
  export { CHAIN_CONFIGS } from './chain-configs';
21
21
 
22
+ // Strategy ↔ deployment resolution (pure, over CHAIN_CONFIGS)
23
+ export {
24
+ StrategyDeploymentInputError,
25
+ strategyDeployments,
26
+ } from './strategy-deployments';
27
+ export type {
28
+ StrategyDeployment,
29
+ StrategyDeploymentInputErrorCode,
30
+ } from './strategy-deployments';
31
+
22
32
  // Directus-backed helpers (I/O — not part of `domain/`)
23
33
  export { fetchUnusedPoolIds } from './unused-pool-ids';
24
34
 
@@ -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,