@kasufinance/kasu-sdk 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +17 -1
  2. package/dist/bundle.cjs.js +1144 -221
  3. package/dist/bundle.esm.js +1141 -222
  4. package/dist/domain/index.d.ts +2 -2
  5. package/dist/domain/index.js +1 -1
  6. package/dist/domain/index.js.map +1 -1
  7. package/dist/domain/yield-history.d.ts +25 -1
  8. package/dist/domain/yield-history.js +41 -11
  9. package/dist/domain/yield-history.js.map +1 -1
  10. package/dist/domain/yields.d.ts +200 -15
  11. package/dist/domain/yields.js +211 -14
  12. package/dist/domain/yields.js.map +1 -1
  13. package/dist/services/Locking/locking.d.ts +12 -0
  14. package/dist/services/Locking/locking.js +16 -0
  15. package/dist/services/Locking/locking.js.map +1 -1
  16. package/dist/services/Portfolio/portfolio.d.ts +54 -1
  17. package/dist/services/Portfolio/portfolio.js +357 -80
  18. package/dist/services/Portfolio/portfolio.js.map +1 -1
  19. package/dist/services/Portfolio/queries.d.ts +16 -0
  20. package/dist/services/Portfolio/queries.js +119 -2
  21. package/dist/services/Portfolio/queries.js.map +1 -1
  22. package/dist/services/Portfolio/types.d.ts +94 -6
  23. package/dist/services/Portfolio/yield-history.d.ts +148 -10
  24. package/dist/services/Portfolio/yield-history.js +241 -31
  25. package/dist/services/Portfolio/yield-history.js.map +1 -1
  26. package/dist/services/UserLending/user-lending.d.ts +16 -0
  27. package/dist/services/UserLending/user-lending.js +24 -0
  28. package/dist/services/UserLending/user-lending.js.map +1 -1
  29. package/dist/services/shared.d.ts +7 -0
  30. package/dist/services/shared.js +48 -0
  31. package/dist/services/shared.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/domain/index.ts +5 -1
  34. package/src/domain/yield-history.ts +47 -11
  35. package/src/domain/yields.ts +257 -15
  36. package/src/services/Locking/locking.ts +17 -0
  37. package/src/services/Portfolio/portfolio.ts +559 -126
  38. package/src/services/Portfolio/queries.ts +119 -2
  39. package/src/services/Portfolio/types.ts +91 -6
  40. package/src/services/Portfolio/yield-history.ts +405 -56
  41. package/src/services/UserLending/user-lending.ts +46 -0
  42. package/src/services/shared.ts +48 -0
@@ -20,6 +20,17 @@
20
20
  */
21
21
  export const SECONDS_PER_WEEK = 604_800;
22
22
 
23
+ /**
24
+ * The most unpaid interest {@link accruedInterestSince} shows for one pool:
25
+ * this many epochs of its weekly figure (owner ruling 2026-11-07).
26
+ *
27
+ * A pool can stop being cleared without being flagged stopped. On Base, one
28
+ * pool is more than six epochs behind and another more than a hundred.
29
+ * Uncapped, the figure would show interest that may never be credited. Four
30
+ * epochs covers any ordinary late clearing.
31
+ */
32
+ export const MAX_ACCRUAL_EPOCHS = 4;
33
+
23
34
  /** One slice of a position that earns at a single rate. */
24
35
  export interface InterestSlice {
25
36
  /** The lender's current balance in this slice, in stable display units. */
@@ -37,7 +48,13 @@ export interface InterestSlice {
37
48
  epochRate: number;
38
49
  }
39
50
 
40
- /** One pool's contribution to the current cycle's accrual. */
51
+ /**
52
+ * One pool's contribution to the current cycle's accrual, anchored on its
53
+ * last clearing TRANSACTION.
54
+ *
55
+ * @deprecated Use {@link EpochAccrual} with {@link accruedInterestSince}. A
56
+ * clearing transaction's timestamp is the wrong anchor: see `accruedInterest`.
57
+ */
41
58
  export interface PoolAccrual {
42
59
  /** That pool's projected net interest for one epoch (`weeklyInterest`). */
43
60
  weeklyInterest: number;
@@ -45,6 +62,29 @@ export interface PoolAccrual {
45
62
  clearingTimestamp: number;
46
63
  }
47
64
 
65
+ /**
66
+ * One pool's contribution to the unpaid interest — the input of
67
+ * {@link accruedInterestSince}. Build it with {@link epochAccrual}, which
68
+ * validates every field and derives the two timestamps; do not assemble one
69
+ * by hand.
70
+ */
71
+ export interface EpochAccrual {
72
+ /** That pool's projected net interest for one epoch (`weeklyInterest`). */
73
+ weeklyInterest: number;
74
+ /**
75
+ * Unix SECONDS from which the pool's unpaid interest accrues: the earlier
76
+ * of the moment its last clearing STARTED and `epochEnd`. Never after
77
+ * `epochEnd`.
78
+ */
79
+ accruesFrom: number;
80
+ /**
81
+ * Unix SECONDS at which the pool's last cleared epoch ENDS
82
+ * (`epochEndTimestamp(initialEpochStart, lastClearedEpoch)`). The accrual
83
+ * holds from `accruesFrom + 604 800` until `epochEnd + 604 800`.
84
+ */
85
+ epochEnd: number;
86
+ }
87
+
48
88
  function isUsableFeePercent(feePercent: number | null): feePercent is number {
49
89
  return (
50
90
  feePercent !== null &&
@@ -102,28 +142,40 @@ export function dailyInterest(weekly: number | null): number | null {
102
142
  }
103
143
 
104
144
  /**
105
- * Interest accrued since each pool's OWN last cycle close — the moving part
106
- * of lifetime interest (lifetime = settled on chain + this).
145
+ * Interest accrued since each pool's OWN last clearing transaction, capped at
146
+ * one epoch per pool.
107
147
  *
108
148
  * ```
109
149
  * accrued = Σ_pool weekly_pool × clamp(now − clearing_pool, 0, 604 800) / 604 800
110
150
  * ```
111
151
  *
112
- * **Per pool, because pools clear independently.** One shared anchor accrues
113
- * a stale pool's share from a close it never had, and when another pool
114
- * clears it drops the total by the stale pool's whole weekly share. Anchored
115
- * per pool, the total is monotonic across any single pool's clearing: that
116
- * pool swaps its accrued share for a capitalised one, and no other term moves.
152
+ * Per pool, because pools clear independently: one shared anchor accrues a
153
+ * stale pool's share from a close it never had, and when another pool clears
154
+ * it drops the total by the stale pool's whole weekly share.
117
155
  *
118
- * **The one-epoch cap is load-bearing.** Clearings are not exactly weekly,
119
- * and `weeklyInterest` is per EPOCH, so extrapolating past seven days would
120
- * promise interest the close will not credit; a late clearing under-reports
121
- * instead. The low clamp makes a close ahead of the local clock zero, never
122
- * negative.
156
+ * **The one-epoch cap rested on a wrong premise.** It assumed a late
157
+ * clearing credits only one epoch, so accruing past seven days would promise
158
+ * interest no close pays. The contracts say otherwise. `ClearingCoordinator`
159
+ * states that clearing "must be executed once per epoch for each lending
160
+ * pool" and that "clearing epochs must be executed in order"; `doClearing`
161
+ * reverts unless the target epoch is the pool's `nextLendingPoolClearingEpoch`,
162
+ * and it calls `LendingPool.applyInterests(targetEpoch)` on every clearing,
163
+ * late or not ("if clearing for the epoch is executed only after the clearing
164
+ * period, only yield will be applied"). Every missed epoch's interest is
165
+ * therefore credited when the clearings catch up — on Base, one pool cleared
166
+ * epochs 116 to 119 in the same block, four weeks credited at once.
167
+ * Capped, the figure freezes as soon as a clearing is a week late; anchored
168
+ * on the transaction, it also DIPS on a catch-up (elapsed resets to zero
169
+ * while only one week is credited per clearing).
123
170
  *
171
+ * The low clamp makes a close ahead of the local clock zero, never negative.
124
172
  * A non-finite term contributes nothing; a non-finite `nowSeconds` accrues
125
- * nothing. Callers fail closed upstream by not offering an accrual for a pool
126
- * whose weekly interest or anchor is unknown.
173
+ * nothing.
174
+ *
175
+ * @deprecated Use {@link accruedInterestSince} with {@link epochAccrual}:
176
+ * identical while clearings run on time, and it keeps accruing, up to four
177
+ * epochs, when they run late. Kept, behaviour-identical, for existing
178
+ * callers.
127
179
  */
128
180
  export function accruedInterest(
129
181
  accruals: readonly PoolAccrual[],
@@ -144,6 +196,196 @@ export function accruedInterest(
144
196
  return accrued;
145
197
  }
146
198
 
199
+ /**
200
+ * Unix SECONDS at which `epoch` ENDS — the start of the next one:
201
+ *
202
+ * ```
203
+ * epochEnd(e) = initialEpochStart + (e + 1) × 604 800
204
+ * ```
205
+ *
206
+ * `SystemVariables.epochStartTimestamp(e)` is `initial + e × EPOCH_DURATION`,
207
+ * and the initial start is written once, at initialisation, so the end of any
208
+ * epoch follows from `epochStartTimestamp(0)` with no further read.
209
+ *
210
+ * FAILS CLOSED with `NaN`: a non-integer, non-finite or negative epoch, an
211
+ * initial start that is not a positive integer, or a result past
212
+ * `Number.MAX_SAFE_INTEGER`.
213
+ *
214
+ * @param initialEpochStart unix seconds, `epochStartTimestamp(0)`.
215
+ * @param epoch the epoch number, 0-based.
216
+ */
217
+ export function epochEndTimestamp(
218
+ initialEpochStart: number,
219
+ epoch: number,
220
+ ): number {
221
+ if (!Number.isSafeInteger(initialEpochStart) || initialEpochStart <= 0) {
222
+ return NaN;
223
+ }
224
+ if (!Number.isSafeInteger(epoch) || epoch < 0) return NaN;
225
+ const end = initialEpochStart + (epoch + 1) * SECONDS_PER_WEEK;
226
+ return Number.isSafeInteger(end) ? end : NaN;
227
+ }
228
+
229
+ /**
230
+ * Builds one pool's {@link EpochAccrual} from what the chain and the subgraph
231
+ * say about its last clearing:
232
+ *
233
+ * ```
234
+ * epochEnd = epochEndTimestamp(initialEpochStart, lastClearedEpoch)
235
+ * accruesFrom = min(clearingStartedAt, epochEnd)
236
+ * ```
237
+ *
238
+ * - **On time** (the clearing started inside its window, before its epoch
239
+ * ended): `accruesFrom` is the clearing's start. Clearing applies the
240
+ * epoch's interest first and only then accepts requests, so today's
241
+ * balance — which includes a deposit that clearing accepted — is never
242
+ * charged for time before it: a new lender starts at exactly 0.
243
+ * - **Late** (it started at or after its epoch end): `accruesFrom` is the
244
+ * epoch end. A late clearing accepts no requests and credits exactly one
245
+ * epoch, so the time it did not credit is kept.
246
+ *
247
+ * `lastClearedEpoch` is the newest epoch whose clearing has STARTED (the
248
+ * subgraph has a `LendingPoolClearing` for it), not the newest that has
249
+ * finished, and `clearingStartedAt` is that clearing's `startTimestamp`.
250
+ * `ClearingCoordinator.doClearing` mints the epoch's interest in its FIRST
251
+ * transaction (`applyInterests`), while `nextLendingPoolClearingEpoch` only
252
+ * advances when the clearing ENDS, so it is the wrong source.
253
+ *
254
+ * FAILS CLOSED with `null`: whatever {@link epochEndTimestamp} rejects, a
255
+ * `clearingStartedAt` that is not a finite positive number, or a
256
+ * `weeklyInterest` that is not finite or is negative. The caller then offers
257
+ * no term for that pool.
258
+ *
259
+ * @param input.weeklyInterest the pool's net interest for one epoch.
260
+ * @param input.initialEpochStart unix seconds, `epochStartTimestamp(0)`.
261
+ * @param input.lastClearedEpoch the newest epoch whose clearing has started.
262
+ * @param input.clearingStartedAt unix seconds, that clearing's start.
263
+ */
264
+ export function epochAccrual(input: {
265
+ weeklyInterest: number;
266
+ initialEpochStart: number;
267
+ lastClearedEpoch: number;
268
+ clearingStartedAt: number;
269
+ }): EpochAccrual | null {
270
+ const { weeklyInterest: weekly, clearingStartedAt } = input;
271
+ if (!Number.isFinite(weekly) || weekly < 0) return null;
272
+ if (!Number.isFinite(clearingStartedAt) || clearingStartedAt <= 0) {
273
+ return null;
274
+ }
275
+ const epochEnd = epochEndTimestamp(
276
+ input.initialEpochStart,
277
+ input.lastClearedEpoch,
278
+ );
279
+ if (Number.isNaN(epochEnd)) return null;
280
+ return {
281
+ weeklyInterest: weekly,
282
+ accruesFrom: Math.min(clearingStartedAt, epochEnd),
283
+ epochEnd,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Interest accrued and not yet credited — the moving part of lifetime
289
+ * interest (lifetime = settled on chain + this). Per term, with
290
+ * `W` = 604 800 s:
291
+ *
292
+ * ```
293
+ * accrued = min( weekly × ( min(max(now − accruesFrom, 0), W)
294
+ * + max(now − (epochEnd + W), 0) ) / W,
295
+ * MAX_ACCRUAL_EPOCHS × weekly )
296
+ * ```
297
+ *
298
+ * Read along the clock, for one pool:
299
+ * 1. from `accruesFrom`, one epoch of interest accrues linearly;
300
+ * 2. it then HOLDS from `accruesFrom + W` until `epochEnd + W`, the end of
301
+ * the next epoch — at most 48 h after an on-time clearing (its clearing
302
+ * window), no time at all after a late one (`accruesFrom == epochEnd`);
303
+ * 3. then it resumes, up to {@link MAX_ACCRUAL_EPOCHS} (4) epochs of
304
+ * `weekly` in all, and stops there.
305
+ *
306
+ * **Why it keeps accruing past one epoch.** Clearing must run once per
307
+ * epoch for each pool, in order (`ClearingCoordinator.doClearing` reverts
308
+ * unless the target epoch is the pool's `nextLendingPoolClearingEpoch`), and
309
+ * every clearing, on time or late, starts with
310
+ * `LendingPool.applyInterests(targetEpoch)`. A missed epoch is therefore
311
+ * credited when the clearings catch up (on Base, one pool cleared epochs 116
312
+ * to 119 in the same block).
313
+ *
314
+ * **Why it stops at four epochs** (owner ruling 2026-11-07). A pool can stop
315
+ * being cleared without being flagged stopped. On Base today one pool is
316
+ * more than six epochs behind and another more than a hundred. Past four
317
+ * epochs the figure would show interest that may never be credited. Four
318
+ * covers any ordinary late clearing.
319
+ *
320
+ * **Why the hold.** Without it an on-time anchor accrues past one epoch
321
+ * while the next on-time clearing — which credits exactly one — is still to
322
+ * come, and the total steps DOWN when it lands. With it, at a constant
323
+ * balance, `settled + accrued` never steps down at a clearing (`t` = the
324
+ * NEW clearing's start, `E` = the end of the epoch it clears):
325
+ * - on time after on time: identical to the deprecated `accruedInterest`
326
+ * at every instant. With `gap` = `t` − the previous clearing's start, it
327
+ * steps UP by `weekly × (W − gap) / W` when `gap < W` (at most 2/7 of one
328
+ * weekly, as before), and not at all when `gap ≥ W`;
329
+ * - late after on time, and late after late: exactly continuous;
330
+ * - on time after late: steps UP by `weekly × (E − t) / W`, at most 2/7 of
331
+ * one weekly.
332
+ * A deposit or top-up accepted at an on-time clearing starts at 0, so it
333
+ * never dips the total either.
334
+ *
335
+ * **At the cap.** All of the above holds for a pool below the cap, where
336
+ * the cap changes nothing. A pool AT the cap shows `4 × weekly` before a
337
+ * catch-up clearing; after it, one more `weekly` is settled and the new term
338
+ * shows `min(elapsed′, 4 × weekly)`, so `after = min(elapsed′ + weekly,
339
+ * 5 × weekly) ≥ before`. Each clearing steps the total UP by at most one
340
+ * weekly until the pool is back under the cap. Never down, and never above
341
+ * settled plus `4 × weekly` per pool.
342
+ *
343
+ * **Never negative**, and non-decreasing in `now`. A term is skipped — it
344
+ * contributes nothing — when `weeklyInterest` is not finite or is negative,
345
+ * when `accruesFrom` or `epochEnd` is not finite, when `accruesFrom >
346
+ * epochEnd` (inconsistent: the anchor is never after the epoch end), or when
347
+ * an elapsed time overflows. A non-finite `nowSeconds` accrues nothing.
348
+ *
349
+ * **Per pool, because pools clear independently.** One shared anchor accrues
350
+ * a stale pool's share from a close it never had.
351
+ *
352
+ * **Accepted approximation.** `weekly` is today's balance at today's epoch
353
+ * rate. Over a multi-week gap the chain compounds weekly and may apply other
354
+ * epochs' rates, so a long accrual reads slightly LOW. It is a ticking
355
+ * estimate, not a ledger.
356
+ *
357
+ * Callers fail closed upstream: no term for a pool whose weekly interest or
358
+ * clearing is unknown (`epochAccrual` returns `null`), and none for a pool
359
+ * that has stopped — it will never clear again.
360
+ */
361
+ export function accruedInterestSince(
362
+ accruals: readonly EpochAccrual[],
363
+ nowSeconds: number,
364
+ ): number {
365
+ if (!Number.isFinite(nowSeconds)) return 0;
366
+ let accrued = 0;
367
+ for (const { weeklyInterest: weekly, accruesFrom, epochEnd } of accruals) {
368
+ if (!Number.isFinite(weekly) || weekly < 0) continue;
369
+ if (!Number.isFinite(accruesFrom) || !Number.isFinite(epochEnd)) {
370
+ continue;
371
+ }
372
+ if (accruesFrom > epochEnd) continue;
373
+ const sinceAnchor = nowSeconds - accruesFrom;
374
+ const sinceHoldEnd = nowSeconds - (epochEnd + SECONDS_PER_WEEK);
375
+ if (!Number.isFinite(sinceAnchor) || !Number.isFinite(sinceHoldEnd)) {
376
+ continue;
377
+ }
378
+ const elapsed =
379
+ Math.min(Math.max(sinceAnchor, 0), SECONDS_PER_WEEK) +
380
+ Math.max(sinceHoldEnd, 0);
381
+ accrued += Math.min(
382
+ (weekly * elapsed) / SECONDS_PER_WEEK,
383
+ MAX_ACCRUAL_EPOCHS * weekly,
384
+ );
385
+ }
386
+ return accrued;
387
+ }
388
+
147
389
  /**
148
390
  * What `amount` earns in a year at a NET rate — the "≈ $Y a year" line.
149
391
  * Feed it `netEffectiveApy(...)`, never a gross APY: the result is a lender's
@@ -625,6 +625,23 @@ export class KSULocking {
625
625
  ).toNumber();
626
626
  }
627
627
 
628
+ /**
629
+ * Unix SECONDS at which epoch 0 started:
630
+ * `SystemVariables.epochStartTimestamp(0)`. The contract writes this once,
631
+ * at initialisation, so it never changes for a deployment and may be cached
632
+ * for the life of the app. Every epoch boundary follows from it —
633
+ * `epochEndTimestamp(initial, epoch)` and `epochAccrual` in the domain
634
+ * layer.
635
+ *
636
+ * Available on every deployment, Full and Lite. A failed read rejects; the
637
+ * caller validates the value (a positive integer) before using it.
638
+ */
639
+ async getInitialEpochStartTimestamp(): Promise<number> {
640
+ return (
641
+ await this._systemVariablesAbi.epochStartTimestamp(0)
642
+ ).toNumber();
643
+ }
644
+
628
645
  async getNextClearingPeriodDate(): Promise<EpochTimeStamp> {
629
646
  const nextEpochDate = await this.getNextEpochDate();
630
647