@kasufinance/kasu-sdk 2.12.0 → 2.13.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.
- package/README.md +26 -1
- package/dist/bundle.cjs.js +871 -41
- package/dist/bundle.esm.js +868 -43
- package/dist/domain/index.d.ts +2 -0
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/domain/pool-tvl.d.ts +62 -0
- package/dist/domain/pool-tvl.js +22 -0
- package/dist/domain/pool-tvl.js.map +1 -0
- package/dist/facade/chain-configs.js +24 -0
- package/dist/facade/chain-configs.js.map +1 -1
- package/dist/facade/kasu.d.ts +2 -0
- package/dist/facade/kasu.js +13 -5
- package/dist/facade/kasu.js.map +1 -1
- package/dist/facade/strategies.d.ts +20 -0
- package/dist/facade/strategies.js +22 -0
- package/dist/facade/strategies.js.map +1 -1
- package/dist/facade/types.d.ts +20 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/kasu-sdk.d.ts +9 -1
- package/dist/kasu-sdk.js +2 -2
- package/dist/kasu-sdk.js.map +1 -1
- package/dist/sdk-config.d.ts +20 -0
- package/dist/sdk-config.js +3 -1
- package/dist/sdk-config.js.map +1 -1
- package/dist/services/DataService/data-service.d.ts +81 -1
- package/dist/services/DataService/data-service.js +211 -6
- package/dist/services/DataService/data-service.js.map +1 -1
- package/dist/services/DataService/tvl-history.d.ts +296 -0
- package/dist/services/DataService/tvl-history.js +558 -0
- package/dist/services/DataService/tvl-history.js.map +1 -0
- package/dist/services/Portfolio/yield-history.d.ts +1 -16
- package/dist/services/Portfolio/yield-history.js +2 -29
- package/dist/services/Portfolio/yield-history.js.map +1 -1
- package/dist/services/shared.d.ts +16 -0
- package/dist/services/shared.js +30 -0
- package/dist/services/shared.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/index.ts +3 -0
- package/src/domain/pool-tvl.ts +66 -0
- package/src/facade/chain-configs.ts +26 -0
- package/src/facade/kasu.ts +20 -2
- package/src/facade/strategies.ts +26 -0
- package/src/facade/types.ts +20 -0
- package/src/index.ts +9 -0
- package/src/kasu-sdk.ts +19 -2
- package/src/sdk-config.ts +22 -0
- package/src/services/DataService/data-service.ts +296 -6
- package/src/services/DataService/tvl-history.ts +853 -0
- package/src/services/Portfolio/yield-history.ts +2 -35
- package/src/services/shared.ts +38 -1
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { BigNumber } from 'ethers';
|
|
2
|
+
import { formatUnits, Interface } from 'ethers/lib/utils';
|
|
3
|
+
import { gql } from 'graphql-request';
|
|
4
|
+
|
|
5
|
+
import { KasuPoolExternalTVLAbi__factory } from '../../contracts';
|
|
6
|
+
import { poolTvl, PoolTvlHistoryEntry } from '../../domain/pool-tvl';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Which part of a pool's TVL history to return, and how long to wait for it.
|
|
10
|
+
* Epochs are counted in the chain's numbering
|
|
11
|
+
* (`SystemVariables.currentEpochNumber`), and the newest epoch is the
|
|
12
|
+
* CURRENT one, which the closing `current` point carries.
|
|
13
|
+
*
|
|
14
|
+
* - neither field: every clearing, then the `current` point;
|
|
15
|
+
* - `count` only: the newest `count` epochs, up to and including the current
|
|
16
|
+
* one (a pool that did not clear in some of them has fewer points);
|
|
17
|
+
* - `fromEpoch` only: `fromEpoch` up to the current epoch;
|
|
18
|
+
* - both: `count` epochs starting at `fromEpoch`, capped at the current
|
|
19
|
+
* epoch. The `current` point is included only when the window reaches the
|
|
20
|
+
* current epoch.
|
|
21
|
+
*/
|
|
22
|
+
export interface PoolTvlHistoryOptions {
|
|
23
|
+
/** First epoch included. A non-negative integer. */
|
|
24
|
+
fromEpoch?: number;
|
|
25
|
+
/** How many epochs. A positive integer. */
|
|
26
|
+
count?: number;
|
|
27
|
+
/**
|
|
28
|
+
* The whole call's time limit, in MILLISECONDS. On expiry it rejects with
|
|
29
|
+
* `PoolTvlHistoryTimeoutError` and starts no further request.
|
|
30
|
+
* @default 30_000
|
|
31
|
+
*/
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Abandons the call: it rejects with `PoolTvlHistoryAbortedError` and
|
|
35
|
+
* starts no further request. Already aborted = rejected before any read.
|
|
36
|
+
*/
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The resolved, inclusive epoch range; `null` when it is empty. */
|
|
41
|
+
export interface PoolTvlHistoryWindow {
|
|
42
|
+
fromEpoch: number;
|
|
43
|
+
toEpoch: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Clearings per subgraph page (the subgraph's own row cap). */
|
|
47
|
+
export const TVL_HISTORY_PAGE_SIZE = 1000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A runaway guard on the clearings pagination, not a working limit: a pool
|
|
51
|
+
* clears once per epoch, so 20 pages would be twenty thousand cycles.
|
|
52
|
+
*/
|
|
53
|
+
export const TVL_HISTORY_MAX_PAGES = 20;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How many past blocks one time-travel request reads, as GraphQL aliases. A
|
|
57
|
+
* pool with ~120 clearings costs two such requests.
|
|
58
|
+
*/
|
|
59
|
+
export const TVL_HISTORY_BLOCKS_PER_REQUEST = 100;
|
|
60
|
+
|
|
61
|
+
/** At most this many past-state RPC reads are in flight at once. */
|
|
62
|
+
export const TVL_HISTORY_RPC_CONCURRENCY = 8;
|
|
63
|
+
|
|
64
|
+
/** Attempts per past-state RPC read (each over every history RPC in turn). */
|
|
65
|
+
export const TVL_HISTORY_RPC_ATTEMPTS = 3;
|
|
66
|
+
|
|
67
|
+
/** Back-off before the second attempt; doubled for each one after. */
|
|
68
|
+
export const TVL_HISTORY_RPC_RETRY_DELAY_MS = 500;
|
|
69
|
+
|
|
70
|
+
/** Per-call timeout of the history providers the SDK builds itself. */
|
|
71
|
+
export const TVL_HISTORY_RPC_TIMEOUT_MS = 10_000;
|
|
72
|
+
|
|
73
|
+
/** Default `timeoutMs` of a whole `getPoolTvlHistory` call. */
|
|
74
|
+
export const TVL_HISTORY_DEFAULT_TIMEOUT_MS = 30_000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Blocks at least this far below the subgraph's head are treated as final
|
|
78
|
+
* and their reads are cached; nearer ones are read again every time.
|
|
79
|
+
*/
|
|
80
|
+
export const TVL_HISTORY_CACHE_DEPTH = 64;
|
|
81
|
+
|
|
82
|
+
/** Entries per SDK instance before the cache starts again from empty. */
|
|
83
|
+
export const TVL_HISTORY_CACHE_LIMIT = 20_000;
|
|
84
|
+
|
|
85
|
+
/** `getPoolTvlHistory` took longer than its `timeoutMs`. */
|
|
86
|
+
export class PoolTvlHistoryTimeoutError extends Error {
|
|
87
|
+
constructor(public readonly timeoutMs: number) {
|
|
88
|
+
super(`getPoolTvlHistory: timed out after ${String(timeoutMs)} ms`);
|
|
89
|
+
this.name = 'PoolTvlHistoryTimeoutError';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `getPoolTvlHistory` was abandoned through its `signal`. */
|
|
94
|
+
export class PoolTvlHistoryAbortedError extends Error {
|
|
95
|
+
constructor() {
|
|
96
|
+
super('getPoolTvlHistory: aborted');
|
|
97
|
+
this.name = 'PoolTvlHistoryAbortedError';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A history provider answered a past block as if nothing were deployed — no
|
|
103
|
+
* code even for the pool, which cleared in that very block. A pruned node
|
|
104
|
+
* does that; trusting it would draw the off-chain TVL as 0.
|
|
105
|
+
*/
|
|
106
|
+
export class PastStateUnavailableError extends Error {
|
|
107
|
+
constructor(public readonly block: number) {
|
|
108
|
+
super(
|
|
109
|
+
`getPoolTvlHistory: history provider does not serve past state (no code for the pool at block ${String(block)}, where it cleared)`,
|
|
110
|
+
);
|
|
111
|
+
this.name = 'PastStateUnavailableError';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Throws `RangeError` / `TypeError` on a malformed option.
|
|
117
|
+
* `getPoolTvlHistory` calls it ONCE, BEFORE any network read;
|
|
118
|
+
* `resolvePoolTvlHistoryWindow` assumes it has run.
|
|
119
|
+
*/
|
|
120
|
+
export function validatePoolTvlHistoryOptions(
|
|
121
|
+
options: PoolTvlHistoryOptions,
|
|
122
|
+
): void {
|
|
123
|
+
const { fromEpoch, count, timeoutMs, signal } = options;
|
|
124
|
+
if (
|
|
125
|
+
fromEpoch !== undefined &&
|
|
126
|
+
(!Number.isSafeInteger(fromEpoch) || fromEpoch < 0)
|
|
127
|
+
) {
|
|
128
|
+
throw new RangeError(
|
|
129
|
+
`getPoolTvlHistory: fromEpoch must be a non-negative integer, got ${String(fromEpoch)}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (count !== undefined && (!Number.isSafeInteger(count) || count < 1)) {
|
|
133
|
+
throw new RangeError(
|
|
134
|
+
`getPoolTvlHistory: count must be a positive integer, got ${String(count)}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
timeoutMs !== undefined &&
|
|
139
|
+
(typeof timeoutMs !== 'number' ||
|
|
140
|
+
!Number.isFinite(timeoutMs) ||
|
|
141
|
+
timeoutMs <= 0)
|
|
142
|
+
) {
|
|
143
|
+
throw new RangeError(
|
|
144
|
+
`getPoolTvlHistory: timeoutMs must be a positive number, got ${String(timeoutMs)}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
signal !== undefined &&
|
|
149
|
+
(typeof signal !== 'object' ||
|
|
150
|
+
typeof (signal as { aborted?: unknown }).aborted !== 'boolean')
|
|
151
|
+
) {
|
|
152
|
+
throw new TypeError('getPoolTvlHistory: signal must be an AbortSignal');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The epoch window. Options must already have passed validation. */
|
|
157
|
+
export function resolvePoolTvlHistoryWindow(
|
|
158
|
+
options: Pick<PoolTvlHistoryOptions, 'fromEpoch' | 'count'>,
|
|
159
|
+
currentEpoch: number,
|
|
160
|
+
): PoolTvlHistoryWindow | null {
|
|
161
|
+
const { fromEpoch, count } = options;
|
|
162
|
+
|
|
163
|
+
let from: number;
|
|
164
|
+
let to = currentEpoch;
|
|
165
|
+
if (fromEpoch !== undefined) {
|
|
166
|
+
from = fromEpoch;
|
|
167
|
+
if (count !== undefined) {
|
|
168
|
+
to = Math.min(currentEpoch, fromEpoch + count - 1);
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
from = count !== undefined ? Math.max(0, currentEpoch - count + 1) : 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return from > to ? null : { fromEpoch: from, toEpoch: to };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Cancellation: one deadline and one optional signal for the whole call
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/** What every stage checks before it starts more work. */
|
|
182
|
+
export interface Cancellation {
|
|
183
|
+
/** Aborted with the call; hand it to `fetch`-based clients. */
|
|
184
|
+
readonly signal: AbortSignal;
|
|
185
|
+
/** Throws the typed reason once the call is timed out or aborted. */
|
|
186
|
+
throwIfCancelled(): void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Runs `work` under a deadline and an optional caller signal. Whichever comes
|
|
191
|
+
* first rejects the returned promise with its typed error at once, aborts
|
|
192
|
+
* `cancellation.signal` (cancelling in-flight subgraph requests) and makes
|
|
193
|
+
* every later `throwIfCancelled()` throw, so no new read starts. An RPC call
|
|
194
|
+
* already sent cannot be recalled; its answer is dropped.
|
|
195
|
+
*/
|
|
196
|
+
export async function runCancellable<T>(
|
|
197
|
+
timeoutMs: number,
|
|
198
|
+
external: AbortSignal | undefined,
|
|
199
|
+
work: (cancellation: Cancellation) => Promise<T>,
|
|
200
|
+
): Promise<T> {
|
|
201
|
+
if (external?.aborted) throw new PoolTvlHistoryAbortedError();
|
|
202
|
+
|
|
203
|
+
const controller = new AbortController();
|
|
204
|
+
let reason: Error | undefined;
|
|
205
|
+
let rejectCancelled: (error: Error) => void = () => undefined;
|
|
206
|
+
const cancelled = new Promise<never>((_, reject) => {
|
|
207
|
+
rejectCancelled = reject;
|
|
208
|
+
});
|
|
209
|
+
const cancel = (error: Error): void => {
|
|
210
|
+
if (reason) return;
|
|
211
|
+
reason = error;
|
|
212
|
+
controller.abort();
|
|
213
|
+
rejectCancelled(error);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const timer = setTimeout(() => {
|
|
217
|
+
cancel(new PoolTvlHistoryTimeoutError(timeoutMs));
|
|
218
|
+
}, timeoutMs);
|
|
219
|
+
const onAbort = (): void => {
|
|
220
|
+
cancel(new PoolTvlHistoryAbortedError());
|
|
221
|
+
};
|
|
222
|
+
external?.addEventListener('abort', onAbort);
|
|
223
|
+
|
|
224
|
+
const cancellation: Cancellation = {
|
|
225
|
+
signal: controller.signal,
|
|
226
|
+
throwIfCancelled: () => {
|
|
227
|
+
if (reason) throw reason;
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const running = work(cancellation);
|
|
233
|
+
// The losing side must never surface as an unhandled rejection.
|
|
234
|
+
running.catch(() => undefined);
|
|
235
|
+
return await Promise.race([running, cancelled]);
|
|
236
|
+
} finally {
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
external?.removeEventListener('abort', onAbort);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Subgraph reads
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/** Above any epoch number: "no upper bound" for the clearings query. */
|
|
247
|
+
const NO_EPOCH_BOUND = '1000000000000';
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The pool's FINISHED clearings, plus — on the first page only — its balance
|
|
251
|
+
* now and the subgraph's latest block.
|
|
252
|
+
*
|
|
253
|
+
* Two modes over one document: ascending from `$epochGte` (a `fromEpoch`
|
|
254
|
+
* window, or everything), or descending below `$epochLt` (the newest N, for
|
|
255
|
+
* a `count`-only window, so it never pages through the whole history).
|
|
256
|
+
*/
|
|
257
|
+
export const poolTvlClearingsQuery = gql`
|
|
258
|
+
query PoolTvlClearings(
|
|
259
|
+
$poolId: ID!
|
|
260
|
+
$pool: String!
|
|
261
|
+
$epochGte: BigInt!
|
|
262
|
+
$epochLt: BigInt!
|
|
263
|
+
$direction: OrderDirection!
|
|
264
|
+
$first: Int!
|
|
265
|
+
$withSnapshot: Boolean!
|
|
266
|
+
) {
|
|
267
|
+
clearings: lendingPoolClearings(
|
|
268
|
+
first: $first
|
|
269
|
+
orderBy: epochId
|
|
270
|
+
orderDirection: $direction
|
|
271
|
+
where: {
|
|
272
|
+
lendingPool: $pool
|
|
273
|
+
isFinished: true
|
|
274
|
+
epochId_gte: $epochGte
|
|
275
|
+
epochId_lt: $epochLt
|
|
276
|
+
}
|
|
277
|
+
) {
|
|
278
|
+
epochId
|
|
279
|
+
endBlockNumber
|
|
280
|
+
endTimestamp
|
|
281
|
+
}
|
|
282
|
+
pool: lendingPool(id: $poolId) @include(if: $withSnapshot) {
|
|
283
|
+
balance
|
|
284
|
+
}
|
|
285
|
+
meta: _meta @include(if: $withSnapshot) {
|
|
286
|
+
block {
|
|
287
|
+
number
|
|
288
|
+
timestamp
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
`;
|
|
293
|
+
|
|
294
|
+
export interface ClearingRow {
|
|
295
|
+
epochId: string;
|
|
296
|
+
endBlockNumber: string;
|
|
297
|
+
endTimestamp: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface PoolTvlSnapshot {
|
|
301
|
+
pool?: { balance: string } | null;
|
|
302
|
+
meta?: { block: { number: number; timestamp: number | null } } | null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface PoolTvlClearingsResult extends PoolTvlSnapshot {
|
|
306
|
+
clearings: ClearingRow[];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The time-travel alias for one block: `b<block>`. */
|
|
310
|
+
export function blockAlias(block: number): string {
|
|
311
|
+
return `b${String(block)}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* One time-travel request: the pool's balance at the END of each block, one
|
|
316
|
+
* alias per block. Block numbers are written into the document, so each must
|
|
317
|
+
* be a positive safe integer — anything else throws rather than reaching the
|
|
318
|
+
* query text. The pool id travels as a variable.
|
|
319
|
+
*/
|
|
320
|
+
export function poolBalancesAtBlocksQuery(blocks: readonly number[]): string {
|
|
321
|
+
const fields = blocks.map((block) => {
|
|
322
|
+
if (!Number.isSafeInteger(block) || block <= 0) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`getPoolTvlHistory: refusing block number ${String(block)}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
return `${blockAlias(block)}: lendingPool(id: $poolId, block: { number: ${String(block)} }) { balance }`;
|
|
328
|
+
});
|
|
329
|
+
return `query PoolTvlAtBlocks($poolId: ID!) { ${fields.join(' ')} }`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export type PoolBalancesAtBlocksResult = Record<
|
|
333
|
+
string,
|
|
334
|
+
{ balance: string } | null
|
|
335
|
+
>;
|
|
336
|
+
|
|
337
|
+
/** One subgraph request; `signal` cancels it. */
|
|
338
|
+
export type GraphRequest = <T>(
|
|
339
|
+
document: string,
|
|
340
|
+
variables: Record<string, unknown>,
|
|
341
|
+
signal?: AbortSignal,
|
|
342
|
+
) => Promise<T>;
|
|
343
|
+
|
|
344
|
+
/** A clearing row parsed and checked. */
|
|
345
|
+
export interface Clearing {
|
|
346
|
+
epochId: number;
|
|
347
|
+
block: number;
|
|
348
|
+
timestamp: number;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function parseClearing(row: ClearingRow): Clearing {
|
|
352
|
+
const clearing = {
|
|
353
|
+
epochId: Number(row.epochId),
|
|
354
|
+
block: Number(row.endBlockNumber),
|
|
355
|
+
timestamp: Number(row.endTimestamp),
|
|
356
|
+
};
|
|
357
|
+
if (
|
|
358
|
+
!Number.isSafeInteger(clearing.epochId) ||
|
|
359
|
+
clearing.epochId < 0 ||
|
|
360
|
+
!Number.isSafeInteger(clearing.block) ||
|
|
361
|
+
clearing.block <= 0 ||
|
|
362
|
+
!Number.isSafeInteger(clearing.timestamp) ||
|
|
363
|
+
clearing.timestamp <= 0
|
|
364
|
+
) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`getPoolTvlHistory: malformed clearing row ${JSON.stringify(row)}`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return clearing;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Which clearings to read: ascending from an epoch, or the newest N. */
|
|
373
|
+
export type ClearingsScope =
|
|
374
|
+
| { kind: 'from'; fromEpoch: number }
|
|
375
|
+
| { kind: 'newest'; count: number };
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The scope's finished clearings, ascending by epoch, and the snapshot from
|
|
379
|
+
* the first page. Normally ONE request.
|
|
380
|
+
*/
|
|
381
|
+
export async function fetchPoolClearings(
|
|
382
|
+
request: GraphRequest,
|
|
383
|
+
poolId: string,
|
|
384
|
+
scope: ClearingsScope,
|
|
385
|
+
cancellation: Cancellation,
|
|
386
|
+
): Promise<{
|
|
387
|
+
clearings: Clearing[];
|
|
388
|
+
snapshot: PoolTvlSnapshot;
|
|
389
|
+
requests: number;
|
|
390
|
+
}> {
|
|
391
|
+
const clearings: Clearing[] = [];
|
|
392
|
+
let snapshot: PoolTvlSnapshot = {};
|
|
393
|
+
let gte = scope.kind === 'from' ? scope.fromEpoch : 0;
|
|
394
|
+
let lt: number | undefined;
|
|
395
|
+
let requests = 0;
|
|
396
|
+
|
|
397
|
+
for (;;) {
|
|
398
|
+
cancellation.throwIfCancelled();
|
|
399
|
+
if (requests >= TVL_HISTORY_MAX_PAGES) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`getPoolTvlHistory: more than ${String(TVL_HISTORY_MAX_PAGES)} pages of clearings for one pool`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
const first = requests === 0;
|
|
405
|
+
const remaining =
|
|
406
|
+
scope.kind === 'newest'
|
|
407
|
+
? scope.count - clearings.length
|
|
408
|
+
: TVL_HISTORY_PAGE_SIZE;
|
|
409
|
+
const pageSize = Math.min(TVL_HISTORY_PAGE_SIZE, remaining);
|
|
410
|
+
const page = await request<PoolTvlClearingsResult>(
|
|
411
|
+
poolTvlClearingsQuery,
|
|
412
|
+
{
|
|
413
|
+
poolId,
|
|
414
|
+
pool: poolId,
|
|
415
|
+
epochGte: String(gte),
|
|
416
|
+
epochLt: lt === undefined ? NO_EPOCH_BOUND : String(lt),
|
|
417
|
+
direction: scope.kind === 'newest' ? 'desc' : 'asc',
|
|
418
|
+
first: pageSize,
|
|
419
|
+
withSnapshot: first,
|
|
420
|
+
},
|
|
421
|
+
cancellation.signal,
|
|
422
|
+
);
|
|
423
|
+
requests += 1;
|
|
424
|
+
if (first) snapshot = { pool: page.pool, meta: page.meta };
|
|
425
|
+
|
|
426
|
+
const rows = page.clearings.map(parseClearing);
|
|
427
|
+
clearings.push(...rows);
|
|
428
|
+
const last = rows.at(-1);
|
|
429
|
+
if (rows.length < pageSize || last === undefined) break;
|
|
430
|
+
if (scope.kind === 'newest') {
|
|
431
|
+
if (clearings.length >= scope.count) break;
|
|
432
|
+
if (lt !== undefined && last.epochId >= lt) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
'getPoolTvlHistory: the clearings cursor did not advance',
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
lt = last.epochId;
|
|
438
|
+
} else {
|
|
439
|
+
if (last.epochId < gte) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
'getPoolTvlHistory: the clearings cursor did not advance',
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
gte = last.epochId + 1;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
clearings.sort((a, b) => a.epochId - b.epochId);
|
|
449
|
+
return { clearings, snapshot, requests };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The pool's balance (display units, the subgraph's decimal string) at the end
|
|
454
|
+
* of each block, `TVL_HISTORY_BLOCKS_PER_REQUEST` blocks per request. A block
|
|
455
|
+
* at which the subgraph has no such pool THROWS: a finished clearing of the
|
|
456
|
+
* pool happened in it, so a missing pool means a broken read, not a zero.
|
|
457
|
+
*/
|
|
458
|
+
export async function fetchPoolBalancesAtBlocks(
|
|
459
|
+
request: GraphRequest,
|
|
460
|
+
poolId: string,
|
|
461
|
+
blocks: readonly number[],
|
|
462
|
+
cancellation: Cancellation,
|
|
463
|
+
): Promise<{ balances: Map<number, string>; requests: number }> {
|
|
464
|
+
const chunks: number[][] = [];
|
|
465
|
+
for (let i = 0; i < blocks.length; i += TVL_HISTORY_BLOCKS_PER_REQUEST) {
|
|
466
|
+
chunks.push(blocks.slice(i, i + TVL_HISTORY_BLOCKS_PER_REQUEST));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
cancellation.throwIfCancelled();
|
|
470
|
+
const pages = await Promise.all(
|
|
471
|
+
chunks.map((chunk) =>
|
|
472
|
+
request<PoolBalancesAtBlocksResult>(
|
|
473
|
+
poolBalancesAtBlocksQuery(chunk),
|
|
474
|
+
{ poolId },
|
|
475
|
+
cancellation.signal,
|
|
476
|
+
).then((page) => ({ chunk, page })),
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const balances = new Map<number, string>();
|
|
481
|
+
for (const { chunk, page } of pages) {
|
|
482
|
+
for (const block of chunk) {
|
|
483
|
+
const state = page[blockAlias(block)];
|
|
484
|
+
if (!state) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`getPoolTvlHistory: the subgraph has no state for the pool at block ${String(block)}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
balances.set(block, state.balance);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return { balances, requests: chunks.length };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
// Off-chain TVL at past blocks
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* What a history provider must answer: an `eth_call` and an `eth_getCode`
|
|
501
|
+
* at a block. Any ethers v5 `Provider` is one.
|
|
502
|
+
*/
|
|
503
|
+
export interface HistoryRpc {
|
|
504
|
+
call(
|
|
505
|
+
transaction: { to: string; data: string },
|
|
506
|
+
blockTag: number | 'latest',
|
|
507
|
+
): Promise<string>;
|
|
508
|
+
getCode(address: string, blockTag: number): Promise<string>;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The past-state reads behind the off-chain TVL. Every method rejects on any
|
|
513
|
+
* failure; none may turn an error into a value.
|
|
514
|
+
*/
|
|
515
|
+
export interface OffChainTvlReader {
|
|
516
|
+
/** `externalTVLOfPool(pool)` at `block`, in the stable token's BASE units. */
|
|
517
|
+
readAt(block: number | 'latest'): Promise<BigNumber>;
|
|
518
|
+
/** Whether the `ExternalTVL` contract has code at `block`. */
|
|
519
|
+
hasCodeAt(block: number): Promise<boolean>;
|
|
520
|
+
/** Whether the POOL contract has code at `block` (the pruned-node check). */
|
|
521
|
+
poolHasCodeAt(block: number): Promise<boolean>;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const EXTERNAL_TVL_INTERFACE: Interface = new Interface(
|
|
525
|
+
KasuPoolExternalTVLAbi__factory.abi,
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
function hasCode(code: unknown): boolean {
|
|
529
|
+
if (typeof code !== 'string' || !/^0x[0-9a-fA-F]*$/.test(code)) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
`getPoolTvlHistory: getCode returned ${String(code)}, not hex`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return code !== '0x';
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The real reader: each read goes to the history RPCs IN ORDER until one
|
|
539
|
+
* answers, and the whole chain is retried `attempts` times with back-off.
|
|
540
|
+
* After the last attempt the last error propagates — never a value. Each
|
|
541
|
+
* attempt first checks the cancellation, so a timed-out call stops here.
|
|
542
|
+
*/
|
|
543
|
+
export function createOffChainTvlReader({
|
|
544
|
+
externalTvl,
|
|
545
|
+
pool,
|
|
546
|
+
rpcs,
|
|
547
|
+
cancellation,
|
|
548
|
+
attempts = TVL_HISTORY_RPC_ATTEMPTS,
|
|
549
|
+
delayMs = TVL_HISTORY_RPC_RETRY_DELAY_MS,
|
|
550
|
+
sleep = (ms: number): Promise<void> =>
|
|
551
|
+
new Promise((resolve) => setTimeout(resolve, ms)),
|
|
552
|
+
}: {
|
|
553
|
+
externalTvl: string;
|
|
554
|
+
pool: string;
|
|
555
|
+
rpcs: readonly HistoryRpc[];
|
|
556
|
+
cancellation: Cancellation;
|
|
557
|
+
attempts?: number;
|
|
558
|
+
delayMs?: number;
|
|
559
|
+
sleep?: (ms: number) => Promise<void>;
|
|
560
|
+
}): OffChainTvlReader {
|
|
561
|
+
if (rpcs.length === 0) {
|
|
562
|
+
throw new Error('getPoolTvlHistory: no history RPC to read from');
|
|
563
|
+
}
|
|
564
|
+
const data = EXTERNAL_TVL_INTERFACE.encodeFunctionData(
|
|
565
|
+
'externalTVLOfPool',
|
|
566
|
+
[pool],
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const read = async <T>(op: (rpc: HistoryRpc) => Promise<T>): Promise<T> => {
|
|
570
|
+
let lastError: unknown;
|
|
571
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
572
|
+
for (const rpc of rpcs) {
|
|
573
|
+
cancellation.throwIfCancelled();
|
|
574
|
+
try {
|
|
575
|
+
return await op(rpc);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
lastError = error;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (attempt < attempts) {
|
|
581
|
+
await sleep(delayMs * 2 ** (attempt - 1));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
cancellation.throwIfCancelled();
|
|
585
|
+
throw lastError;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
readAt: (block) =>
|
|
590
|
+
read(async (rpc) => {
|
|
591
|
+
const result = await rpc.call({ to: externalTvl, data }, block);
|
|
592
|
+
const [value] = EXTERNAL_TVL_INTERFACE.decodeFunctionResult(
|
|
593
|
+
'externalTVLOfPool',
|
|
594
|
+
result,
|
|
595
|
+
) as unknown as [BigNumber];
|
|
596
|
+
return value;
|
|
597
|
+
}),
|
|
598
|
+
hasCodeAt: (block) =>
|
|
599
|
+
read(async (rpc) => hasCode(await rpc.getCode(externalTvl, block))),
|
|
600
|
+
poolHasCodeAt: (block) =>
|
|
601
|
+
read(async (rpc) => hasCode(await rpc.getCode(pool, block))),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Index of the first block (of `blocks`, ascending) at which the contract has
|
|
607
|
+
* code; `blocks.length` when it has code at none. A deployed contract keeps
|
|
608
|
+
* its code (it is a proxy, never self-destructed), so the answer is a single
|
|
609
|
+
* boundary and a binary search finds it: ONE `getCode` when the contract
|
|
610
|
+
* already existed at the oldest clearing, about log2(n) otherwise.
|
|
611
|
+
*/
|
|
612
|
+
export async function firstBlockWithCode(
|
|
613
|
+
blocks: readonly number[],
|
|
614
|
+
hasCodeAt: (block: number) => Promise<boolean>,
|
|
615
|
+
): Promise<number> {
|
|
616
|
+
if (blocks.length === 0) return 0;
|
|
617
|
+
if (await hasCodeAt(blocks[0])) return 0;
|
|
618
|
+
const last = blocks.length - 1;
|
|
619
|
+
if (!(await hasCodeAt(blocks[last]))) return blocks.length;
|
|
620
|
+
let lo = 0; // no code
|
|
621
|
+
let hi = last; // code
|
|
622
|
+
while (hi - lo > 1) {
|
|
623
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
624
|
+
if (await hasCodeAt(blocks[mid])) hi = mid;
|
|
625
|
+
else lo = mid;
|
|
626
|
+
}
|
|
627
|
+
return hi;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* `fn` over `items`, at most `limit` in flight; rejects on the first failure
|
|
632
|
+
* and starts nothing after it (or after `beforeEach` throws).
|
|
633
|
+
*/
|
|
634
|
+
export async function mapWithConcurrency<T, R>(
|
|
635
|
+
items: readonly T[],
|
|
636
|
+
limit: number,
|
|
637
|
+
fn: (item: T) => Promise<R>,
|
|
638
|
+
beforeEach: () => void = (): void => undefined,
|
|
639
|
+
): Promise<R[]> {
|
|
640
|
+
const results = new Array<R>(items.length);
|
|
641
|
+
let next = 0;
|
|
642
|
+
let failed = false;
|
|
643
|
+
const worker = async (): Promise<void> => {
|
|
644
|
+
while (!failed && next < items.length) {
|
|
645
|
+
const index = next++;
|
|
646
|
+
try {
|
|
647
|
+
beforeEach();
|
|
648
|
+
results[index] = await fn(items[index]);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
failed = true;
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
await Promise.all(
|
|
656
|
+
Array.from({ length: Math.min(limit, items.length) }, worker),
|
|
657
|
+
);
|
|
658
|
+
return results;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Immutable past-block reads, per SDK instance. Keys carry the contract (or
|
|
663
|
+
* subgraph), the pool and the block, so two deployments never collide.
|
|
664
|
+
*/
|
|
665
|
+
export class PastBlockCache {
|
|
666
|
+
private readonly _entries = new Map<string, unknown>();
|
|
667
|
+
|
|
668
|
+
get<T>(key: string): T | undefined {
|
|
669
|
+
return this._entries.get(key) as T | undefined;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
set(key: string, value: unknown): void {
|
|
673
|
+
if (this._entries.size >= TVL_HISTORY_CACHE_LIMIT) {
|
|
674
|
+
this._entries.clear();
|
|
675
|
+
}
|
|
676
|
+
this._entries.set(key, value);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
get size(): number {
|
|
680
|
+
return this._entries.size;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The off-chain TVL (BASE units) at each block, and — with `withLatest` — at
|
|
686
|
+
* the latest block, all inside ONE bounded pool of reads.
|
|
687
|
+
*
|
|
688
|
+
* Before the contract existed there was nothing to record, so those blocks
|
|
689
|
+
* are 0 — decided by `getCode`, never by catching a revert: an RPC that
|
|
690
|
+
* refuses past state can surface that refusal as a "call exception". And a
|
|
691
|
+
* "no code" answer is believed only if the same provider DOES show code for
|
|
692
|
+
* the pool at the oldest and newest such block (it cleared there); otherwise
|
|
693
|
+
* `PastStateUnavailableError`. Every other failed read throws.
|
|
694
|
+
*
|
|
695
|
+
* `cached` blocks (final, below the head) are answered from `cache` and
|
|
696
|
+
* stored there after a successful read.
|
|
697
|
+
*/
|
|
698
|
+
export async function fetchOffChainTvlAtBlocks(
|
|
699
|
+
reader: OffChainTvlReader,
|
|
700
|
+
blocks: readonly number[],
|
|
701
|
+
{
|
|
702
|
+
withLatest = false,
|
|
703
|
+
cancellation,
|
|
704
|
+
cache,
|
|
705
|
+
cacheKey,
|
|
706
|
+
cacheable = (): boolean => false,
|
|
707
|
+
}: {
|
|
708
|
+
withLatest?: boolean;
|
|
709
|
+
cancellation: Cancellation;
|
|
710
|
+
cache?: PastBlockCache;
|
|
711
|
+
cacheKey?: (kind: 'tvl' | 'code' | 'pool', block: number) => string;
|
|
712
|
+
cacheable?: (block: number) => boolean;
|
|
713
|
+
},
|
|
714
|
+
): Promise<{
|
|
715
|
+
offChain: Map<number, BigNumber>;
|
|
716
|
+
latest: BigNumber | null;
|
|
717
|
+
rpcReads: number;
|
|
718
|
+
}> {
|
|
719
|
+
let rpcReads = 0;
|
|
720
|
+
const cached = <T>(
|
|
721
|
+
kind: 'tvl' | 'code' | 'pool',
|
|
722
|
+
block: number,
|
|
723
|
+
load: () => Promise<T>,
|
|
724
|
+
): Promise<T> => {
|
|
725
|
+
const key =
|
|
726
|
+
cache && cacheKey && cacheable(block)
|
|
727
|
+
? cacheKey(kind, block)
|
|
728
|
+
: undefined;
|
|
729
|
+
const hit = key === undefined ? undefined : cache?.get<T>(key);
|
|
730
|
+
if (hit !== undefined) return Promise.resolve(hit);
|
|
731
|
+
cancellation.throwIfCancelled();
|
|
732
|
+
rpcReads += 1;
|
|
733
|
+
return load().then((value) => {
|
|
734
|
+
if (key !== undefined) cache?.set(key, value);
|
|
735
|
+
return value;
|
|
736
|
+
});
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const deployedFrom = await firstBlockWithCode(blocks, (block) =>
|
|
740
|
+
cached('code', block, () => reader.hasCodeAt(block)),
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
if (deployedFrom > 0) {
|
|
744
|
+
for (const block of new Set([blocks[0], blocks[deployedFrom - 1]])) {
|
|
745
|
+
const poolDeployed = await cached('pool', block, () =>
|
|
746
|
+
reader.poolHasCodeAt(block),
|
|
747
|
+
);
|
|
748
|
+
if (!poolDeployed) throw new PastStateUnavailableError(block);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const offChain = new Map<number, BigNumber>();
|
|
753
|
+
for (const block of blocks.slice(0, deployedFrom)) {
|
|
754
|
+
offChain.set(block, BigNumber.from(0));
|
|
755
|
+
}
|
|
756
|
+
const targets: (number | 'latest')[] = [...blocks.slice(deployedFrom)];
|
|
757
|
+
if (withLatest) targets.push('latest');
|
|
758
|
+
const values = await mapWithConcurrency(
|
|
759
|
+
targets,
|
|
760
|
+
TVL_HISTORY_RPC_CONCURRENCY,
|
|
761
|
+
(block) => {
|
|
762
|
+
if (block !== 'latest') {
|
|
763
|
+
return cached('tvl', block, () => reader.readAt(block));
|
|
764
|
+
}
|
|
765
|
+
// Never cached: "latest" moves.
|
|
766
|
+
rpcReads += 1;
|
|
767
|
+
return reader.readAt('latest');
|
|
768
|
+
},
|
|
769
|
+
() => {
|
|
770
|
+
cancellation.throwIfCancelled();
|
|
771
|
+
},
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
let latest: BigNumber | null = null;
|
|
775
|
+
targets.forEach((block, i) => {
|
|
776
|
+
if (block === 'latest') latest = values[i];
|
|
777
|
+
else offChain.set(block, values[i]);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
return { offChain, latest, rpcReads };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
// Assembly
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
export interface PoolTvlHistoryRead {
|
|
788
|
+
/** Finished clearings inside the window. */
|
|
789
|
+
clearings: Clearing[];
|
|
790
|
+
/** On-chain balance (display units) at each distinct clearing block. */
|
|
791
|
+
balances: ReadonlyMap<number, string>;
|
|
792
|
+
/** Off-chain TVL (BASE units) at each distinct clearing block. */
|
|
793
|
+
offChain: ReadonlyMap<number, BigNumber>;
|
|
794
|
+
/** The closing point's inputs; `null` when the window stops short of now. */
|
|
795
|
+
current: {
|
|
796
|
+
epochId: number;
|
|
797
|
+
timestamp: number;
|
|
798
|
+
balance: string;
|
|
799
|
+
offChain: BigNumber;
|
|
800
|
+
} | null;
|
|
801
|
+
/** The stable token's decimals, for the off-chain BASE units. */
|
|
802
|
+
decimals: number;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Pure: the reads → the history. One `clearing` point per clearing, ordered by
|
|
807
|
+
* the block it finished in (then by epoch, for clearings that shared a block),
|
|
808
|
+
* so the series is ascending in TIME even where epoch numbers jump; then the
|
|
809
|
+
* `current` point. Each point is `poolTvl(onChain, offChain)` — the same
|
|
810
|
+
* function `getPoolOverview` uses for `totalValueLocked.total`.
|
|
811
|
+
*/
|
|
812
|
+
export function buildPoolTvlHistory(
|
|
813
|
+
read: PoolTvlHistoryRead,
|
|
814
|
+
): PoolTvlHistoryEntry[] {
|
|
815
|
+
const offChainDisplay = (value: BigNumber): string =>
|
|
816
|
+
formatUnits(value, read.decimals);
|
|
817
|
+
|
|
818
|
+
const entries: PoolTvlHistoryEntry[] = [...read.clearings]
|
|
819
|
+
.sort((a, b) => a.block - b.block || a.epochId - b.epochId)
|
|
820
|
+
.map((clearing) => {
|
|
821
|
+
const balance = read.balances.get(clearing.block);
|
|
822
|
+
const offChain = read.offChain.get(clearing.block);
|
|
823
|
+
if (balance === undefined || offChain === undefined) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`getPoolTvlHistory: no reading at block ${String(clearing.block)}`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
epochId: clearing.epochId,
|
|
830
|
+
timestamp: clearing.timestamp,
|
|
831
|
+
tvl: poolTvl(balance, offChainDisplay(offChain)),
|
|
832
|
+
kind: 'clearing' as const,
|
|
833
|
+
};
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
if (read.current) {
|
|
837
|
+
entries.push({
|
|
838
|
+
epochId: read.current.epochId,
|
|
839
|
+
timestamp: read.current.timestamp,
|
|
840
|
+
tvl: poolTvl(
|
|
841
|
+
read.current.balance,
|
|
842
|
+
offChainDisplay(read.current.offChain),
|
|
843
|
+
),
|
|
844
|
+
kind: 'current',
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
return entries;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Ascending, de-duplicated clearing blocks: each past block is read ONCE. */
|
|
851
|
+
export function distinctBlocks(clearings: readonly Clearing[]): number[] {
|
|
852
|
+
return [...new Set(clearings.map((c) => c.block))].sort((a, b) => a - b);
|
|
853
|
+
}
|