@kasufinance/kasu-sdk 2.4.2 → 2.6.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 (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -253
  3. package/dist/bundle.cjs.js +5472 -1710
  4. package/dist/bundle.esm.js +5415 -1713
  5. package/dist/domain/au-minimum.d.ts +135 -0
  6. package/dist/domain/au-minimum.js +154 -0
  7. package/dist/domain/au-minimum.js.map +1 -0
  8. package/dist/domain/au-minimum.test.js +202 -0
  9. package/dist/domain/au-minimum.test.js.map +1 -0
  10. package/dist/domain/deposit-bounds.d.ts +62 -0
  11. package/dist/domain/deposit-bounds.js +84 -0
  12. package/dist/domain/deposit-bounds.js.map +1 -0
  13. package/dist/domain/deposit-bounds.test.d.ts +1 -0
  14. package/dist/domain/deposit-bounds.test.js +135 -0
  15. package/dist/domain/deposit-bounds.test.js.map +1 -0
  16. package/dist/domain/index.d.ts +40 -0
  17. package/dist/domain/index.js +33 -0
  18. package/dist/domain/index.js.map +1 -0
  19. package/dist/domain/loan-contract.d.ts +174 -0
  20. package/dist/domain/loan-contract.js +160 -0
  21. package/dist/domain/loan-contract.js.map +1 -0
  22. package/dist/domain/loan-contract.test.d.ts +1 -0
  23. package/dist/domain/loan-contract.test.js +255 -0
  24. package/dist/domain/loan-contract.test.js.map +1 -0
  25. package/dist/domain/partners.d.ts +34 -0
  26. package/dist/domain/partners.js +43 -0
  27. package/dist/domain/partners.js.map +1 -0
  28. package/dist/domain/partners.test.d.ts +1 -0
  29. package/dist/domain/partners.test.js +53 -0
  30. package/dist/domain/partners.test.js.map +1 -0
  31. package/dist/domain/pools.d.ts +70 -0
  32. package/dist/domain/pools.js +134 -0
  33. package/dist/domain/pools.js.map +1 -0
  34. package/dist/domain/pools.test.d.ts +1 -0
  35. package/dist/domain/pools.test.js +184 -0
  36. package/dist/domain/pools.test.js.map +1 -0
  37. package/dist/domain/rates.d.ts +83 -0
  38. package/dist/domain/rates.js +120 -0
  39. package/dist/domain/rates.js.map +1 -0
  40. package/dist/domain/rates.test.d.ts +1 -0
  41. package/dist/domain/rates.test.js +181 -0
  42. package/dist/domain/rates.test.js.map +1 -0
  43. package/dist/domain/requests.d.ts +181 -0
  44. package/dist/domain/requests.js +202 -0
  45. package/dist/domain/requests.js.map +1 -0
  46. package/dist/domain/requests.test.d.ts +1 -0
  47. package/dist/domain/requests.test.js +470 -0
  48. package/dist/domain/requests.test.js.map +1 -0
  49. package/dist/domain/settlement.d.ts +97 -0
  50. package/dist/domain/settlement.js +117 -0
  51. package/dist/domain/settlement.js.map +1 -0
  52. package/dist/domain/settlement.test.d.ts +1 -0
  53. package/dist/domain/settlement.test.js +152 -0
  54. package/dist/domain/settlement.test.js.map +1 -0
  55. package/dist/domain/tranche-display-name.d.ts +17 -0
  56. package/dist/domain/tranche-display-name.js +30 -0
  57. package/dist/domain/tranche-display-name.js.map +1 -0
  58. package/dist/domain/tranche-display-name.test.d.ts +1 -0
  59. package/dist/domain/tranche-display-name.test.js +58 -0
  60. package/dist/domain/tranche-display-name.test.js.map +1 -0
  61. package/dist/domain/tranches.d.ts +89 -0
  62. package/dist/domain/tranches.js +150 -0
  63. package/dist/domain/tranches.js.map +1 -0
  64. package/dist/domain/tranches.test.d.ts +1 -0
  65. package/dist/domain/tranches.test.js +206 -0
  66. package/dist/domain/tranches.test.js.map +1 -0
  67. package/dist/domain/wallet-errors.d.ts +37 -0
  68. package/dist/domain/wallet-errors.js +56 -0
  69. package/dist/domain/wallet-errors.js.map +1 -0
  70. package/dist/domain/wallet-errors.test.d.ts +1 -0
  71. package/dist/domain/wallet-errors.test.js +71 -0
  72. package/dist/domain/wallet-errors.test.js.map +1 -0
  73. package/dist/facade/chain-configs.js +61 -1
  74. package/dist/facade/chain-configs.js.map +1 -1
  75. package/dist/facade/config.test.d.ts +1 -0
  76. package/dist/facade/config.test.js +216 -0
  77. package/dist/facade/config.test.js.map +1 -0
  78. package/dist/facade/deposits.d.ts +22 -1
  79. package/dist/facade/deposits.js +24 -1
  80. package/dist/facade/deposits.js.map +1 -1
  81. package/dist/facade/facade.test.d.ts +1 -0
  82. package/dist/{tests → facade}/facade.test.js +87 -5
  83. package/dist/facade/facade.test.js.map +1 -0
  84. package/dist/facade/index.d.ts +2 -1
  85. package/dist/facade/index.js +2 -0
  86. package/dist/facade/index.js.map +1 -1
  87. package/dist/facade/kasu.d.ts +49 -8
  88. package/dist/facade/kasu.js +117 -13
  89. package/dist/facade/kasu.js.map +1 -1
  90. package/dist/facade/strategies.d.ts +25 -0
  91. package/dist/facade/strategies.js +36 -0
  92. package/dist/facade/strategies.js.map +1 -1
  93. package/dist/facade/types.d.ts +57 -2
  94. package/dist/facade/unused-pool-ids.d.ts +31 -0
  95. package/dist/facade/unused-pool-ids.js +57 -0
  96. package/dist/facade/unused-pool-ids.js.map +1 -0
  97. package/dist/facade/user-portfolio.d.ts +18 -0
  98. package/dist/facade/user-portfolio.js +23 -0
  99. package/dist/facade/user-portfolio.js.map +1 -1
  100. package/dist/index.d.ts +2 -0
  101. package/dist/index.js +6 -0
  102. package/dist/index.js.map +1 -1
  103. package/dist/sdk-config.d.ts +11 -0
  104. package/dist/sdk-config.js +8 -1
  105. package/dist/sdk-config.js.map +1 -1
  106. package/dist/services/DataService/data-service.d.ts +5 -0
  107. package/dist/services/DataService/data-service.js +10 -12
  108. package/dist/services/DataService/data-service.js.map +1 -1
  109. package/dist/services/DataService/directus-client.d.ts +26 -0
  110. package/dist/services/DataService/directus-client.js +38 -0
  111. package/dist/services/DataService/directus-client.js.map +1 -0
  112. package/dist/services/Locking/calculate-apy.test.d.ts +1 -0
  113. package/dist/services/Locking/calculate-apy.test.js +41 -0
  114. package/dist/services/Locking/calculate-apy.test.js.map +1 -0
  115. package/dist/services/Locking/locking.d.ts +14 -0
  116. package/dist/services/Locking/locking.js +16 -2
  117. package/dist/services/Locking/locking.js.map +1 -1
  118. package/dist/services/UserLending/user-lending.js +11 -7
  119. package/dist/services/UserLending/user-lending.js.map +1 -1
  120. package/dist/tests/sample.test.js +46 -25
  121. package/dist/tests/sample.test.js.map +1 -1
  122. package/package.json +43 -12
  123. package/src/domain/au-minimum.test.ts +371 -0
  124. package/src/domain/au-minimum.ts +192 -0
  125. package/src/domain/deposit-bounds.test.ts +200 -0
  126. package/src/domain/deposit-bounds.ts +118 -0
  127. package/src/domain/index.ts +134 -0
  128. package/src/domain/loan-contract.test.ts +343 -0
  129. package/src/domain/loan-contract.ts +275 -0
  130. package/src/domain/partners.test.ts +83 -0
  131. package/src/domain/partners.ts +66 -0
  132. package/src/domain/pools.test.ts +260 -0
  133. package/src/domain/pools.ts +150 -0
  134. package/src/domain/rates.test.ts +254 -0
  135. package/src/domain/rates.ts +120 -0
  136. package/src/domain/requests.test.ts +653 -0
  137. package/src/domain/requests.ts +414 -0
  138. package/src/domain/settlement.test.ts +198 -0
  139. package/src/domain/settlement.ts +161 -0
  140. package/src/domain/tranche-display-name.test.ts +96 -0
  141. package/src/domain/tranche-display-name.ts +34 -0
  142. package/src/domain/tranches.test.ts +300 -0
  143. package/src/domain/tranches.ts +177 -0
  144. package/src/domain/wallet-errors.test.ts +100 -0
  145. package/src/domain/wallet-errors.ts +56 -0
  146. package/src/facade/chain-configs.ts +62 -1
  147. package/src/facade/config.test.ts +265 -0
  148. package/src/facade/deposits.ts +25 -0
  149. package/src/{tests → facade}/facade.test.ts +134 -5
  150. package/src/facade/index.ts +4 -0
  151. package/src/facade/kasu.ts +155 -12
  152. package/src/facade/strategies.ts +36 -0
  153. package/src/facade/types.ts +60 -2
  154. package/src/facade/unused-pool-ids.ts +59 -0
  155. package/src/facade/user-portfolio.ts +24 -0
  156. package/src/index.ts +8 -0
  157. package/src/sdk-config.ts +19 -1
  158. package/src/services/DataService/data-service.ts +14 -26
  159. package/src/services/DataService/directus-client.ts +54 -0
  160. package/src/services/Locking/calculate-apy.test.ts +52 -0
  161. package/src/services/Locking/locking.ts +16 -2
  162. package/src/services/UserLending/user-lending.ts +17 -21
  163. package/src/tests/sample.test.ts +54 -33
  164. package/.eslintignore +0 -3
  165. package/.eslintrc.json +0 -90
  166. package/.github/workflows/ci.yml +0 -39
  167. package/.github/workflows/gitleaks.yml +0 -22
  168. package/.github/workflows/main.yml +0 -41
  169. package/.gitleaksignore +0 -19
  170. package/.prettierignore +0 -3
  171. package/.prettierrc +0 -28
  172. package/CLAUDE.md +0 -410
  173. package/copy-abi.ps1 +0 -15
  174. package/dist/tests/facade.test.js.map +0 -1
  175. package/index.ts +0 -2
  176. package/jest-config.ts +0 -9
  177. package/jest.config.js +0 -8
  178. package/rollup.config.js +0 -45
  179. package/tsconfig.json +0 -27
  180. /package/dist/{tests/facade.test.d.ts → domain/au-minimum.test.d.ts} +0 -0
@@ -0,0 +1,414 @@
1
+ import { UserRequestStatus } from '../services/UserLending/subgraph-types';
2
+ import {
3
+ UserRequest,
4
+ UserRequestEvent,
5
+ } from '../services/UserLending/types';
6
+
7
+ /**
8
+ * The lending-request view model, as CODES.
9
+ *
10
+ * Lifted verbatim from kasu-ui's `derive-transaction-view.ts` +
11
+ * `request-bundle.ts`, minus every word. kasu-ui's `deriveTransactionView`
12
+ * produces a `TransactionView` that mixes derived FACTS (status code, amounts,
13
+ * kind, ids, submission count, timestamps) with COPY (the status word, the
14
+ * type label, the detail line beneath it, tooltip keys). Only the facts belong
15
+ * in this layer: the copy stays in each application, where the design system,
16
+ * the register and the visitor's language are.
17
+ *
18
+ * The branch ORDER below is the behaviour — cancelled beats forced beats
19
+ * reallocated beats a live withdrawal remainder beats the resolved outcomes
20
+ * beats pending. It reproduces `deriveTransactionView` check for check, so a
21
+ * consumer that renders its own words on top of `statusCode` gets exactly the
22
+ * row kasu-ui has been showing.
23
+ */
24
+
25
+ /**
26
+ * Stable status code for filters, tests and the caller's own status word.
27
+ * Independent of any user-facing copy — kasu-ui's four-value vocabulary
28
+ * (Queued / Processing / Completed / Cancelled) is a rendering of these seven
29
+ * codes, not a replacement for them.
30
+ */
31
+ export type RequestStatusCode =
32
+ | 'pending'
33
+ | 'complete'
34
+ | 'partial'
35
+ | 'rejected'
36
+ | 'reallocated'
37
+ | 'cancelled'
38
+ | 'forced';
39
+
40
+ /**
41
+ * Direction of value flow into / out of the lender's balance. `neutral`
42
+ * applies when the request was cancelled or fully rejected — nothing moved.
43
+ */
44
+ export type RequestKind = 'inflow' | 'outflow' | 'neutral';
45
+
46
+ /** One derived request row. Every field is a number, a code or an id. */
47
+ export interface RequestState {
48
+ /** `UserRequest.id`. */
49
+ id: string;
50
+ /**
51
+ * Id the loan contract is retrieved by — sourced from the `Initiated`
52
+ * event (format `${requestId}-${index}`). The agreements upstream keys
53
+ * contract metadata by EVENT id, not request id; passing `id` returns
54
+ * "Signature data not found". Empty string when there is no `Initiated`
55
+ * event (e.g. a withdrawal).
56
+ */
57
+ contractId: string;
58
+ /** Pool address, lowercased — the filter/join key. */
59
+ poolId: string;
60
+ /**
61
+ * Pool name exactly as it arrived. RAW: splitting it into a strategy name
62
+ * and its borrower-type subheading (kasu-ui's `splitPoolName`) is a
63
+ * display decision and stays in the application.
64
+ */
65
+ poolName: string;
66
+ /**
67
+ * Tranche name exactly as the subgraph reports it. RAW: apps call
68
+ * `getTrancheDisplayName(state.trancheName, { poolName: state.poolName })`
69
+ * at the view boundary. A renamed value must never reach matching or
70
+ * ranking code.
71
+ */
72
+ trancheName: string;
73
+ /** Tranche id — the resolve API re-renders a fixed-term contract against it. */
74
+ trancheId: string;
75
+ /** Fixed-term configId, `'0'` for a variable-rate request. */
76
+ fixedTermConfigId: string;
77
+ requestType: 'Deposit' | 'Withdrawal';
78
+ /** The subgraph status, untouched. Sticky — see `cycleClosed`. */
79
+ rawStatus: UserRequest['status'];
80
+ statusCode: RequestStatusCode;
81
+ kind: RequestKind;
82
+ /**
83
+ * The amount originally asked for, always positive. A cancelled request
84
+ * has its on-request `requestedAmount` zeroed by the subgraph, so the
85
+ * original is recovered from the `Initiated` event (see
86
+ * `initiatedAmount`).
87
+ */
88
+ requestedAmount: number;
89
+ /**
90
+ * The accepted figure as the subgraph reported it, or `null` when it
91
+ * reported none. `null` is NOT zero: an absent figure means "not known",
92
+ * and a caller that renders it as 0 states an outcome the chain has not
93
+ * given. The branch logic below reads `null` as 0 — exactly what kasu-ui's
94
+ * `num()` does — but the distinction survives into the row.
95
+ */
96
+ acceptedAmount: number | null;
97
+ /**
98
+ * `assetAmount` of the `Initiated` event, or `null` when the request
99
+ * carries no `Initiated` event. This is the cancelled-amount recovery:
100
+ * the subgraph resets a cancelled request's `requestedAmount` to 0 (the
101
+ * lender's effective balance is restored) and the original survives only
102
+ * here.
103
+ */
104
+ initiatedAmount: number | null;
105
+ /**
106
+ * The signed figure the row shows, chosen by the same branch logic
107
+ * kasu-ui uses: positive for an inflow, negative for an outflow, 0 for a
108
+ * cancelled or fully-rejected request.
109
+ */
110
+ amount: number;
111
+ /**
112
+ * Submissions bundled into this dNFT-aggregate row (`Initiated` +
113
+ * `Increased`). `> 1` is what drives kasu-ui's inline "(x2)".
114
+ */
115
+ submissionCount: number;
116
+ /**
117
+ * Timestamp of the FIRST submission in the bundle, or `null` when the
118
+ * bundle carries no submission event yet (the `Initiated` event has not
119
+ * indexed). kasu-ui substitutes `request.timestamp` there; that fallback
120
+ * is the application's to choose, so this layer reports the absence.
121
+ */
122
+ firstSubmissionTimestamp: number | null;
123
+ /** Has THIS request's cycle closed? — `!canCancel`. See `isCycleClosed`. */
124
+ cycleClosed: boolean;
125
+ /** The SDK's per-pool cancel signal, passed through. */
126
+ canCancel: boolean;
127
+ }
128
+
129
+ /**
130
+ * Events that represent a lender SUBMISSION into the bundle. A dNFT position
131
+ * aggregates every submission the lender made into the same pool/tranche this
132
+ * cycle: the first is `Initiated`, each subsequent top-up is `Increased`.
133
+ * Everything else on the timeline (Accepted / Rejected / Cancelled /
134
+ * Reallocated / Forced) is an OUTCOME, not a request, and must not be counted.
135
+ */
136
+ const SUBMISSION_EVENTS: ReadonlySet<UserRequestEvent['requestType']> = new Set(
137
+ ['Initiated', 'Increased'],
138
+ );
139
+
140
+ /**
141
+ * The submissions bundled into one dNFT-aggregate request row, input order
142
+ * preserved — one loan agreement per submission.
143
+ */
144
+ export function submissionEvents<
145
+ T extends Pick<UserRequestEvent, 'requestType'>,
146
+ >(events: T[]): T[] {
147
+ return events.filter((e) => SUBMISSION_EVENTS.has(e.requestType));
148
+ }
149
+
150
+ /** Count the submissions bundled into one dNFT-aggregate request row. */
151
+ export function countSubmissions(
152
+ events: Pick<UserRequestEvent, 'requestType'>[],
153
+ ): number {
154
+ return submissionEvents(events).length;
155
+ }
156
+
157
+ /**
158
+ * Timestamp of the FIRST submission in the bundle. Falls back to `fallback`
159
+ * when the `Initiated` event has not indexed yet — the caller decides what
160
+ * that is (kasu-ui passes the request's own timestamp).
161
+ */
162
+ export function firstSubmissionTimestamp(
163
+ events: Pick<UserRequestEvent, 'requestType' | 'timestamp'>[],
164
+ fallback: number,
165
+ ): number {
166
+ const submissions = submissionEvents(events);
167
+ if (submissions.length === 0) return fallback;
168
+ return submissions.reduce(
169
+ (min, e) => (e.timestamp < min ? e.timestamp : min),
170
+ Infinity,
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Has THIS request's cycle closed? — the single open/closed signal behind the
176
+ * status vocabulary and behind Cancel.
177
+ *
178
+ * `request.canCancel` is the SDK's `isCancelable(status, poolId)` —
179
+ * `status !== 'Processed' && !isLendingPoolClearingPending(pool)`. It is
180
+ * per-POOL and reads the same condition the contract enforces on the cancel
181
+ * call — but it reads it ONCE, when the request was fetched. Nothing about
182
+ * this value is live, so a client that holds a request across a cycle close
183
+ * must refetch before acting on it.
184
+ *
185
+ * The raw subgraph `status` must NOT feed this: `'Processing'` is a STICKY
186
+ * historical marker set on the first partial fill and never reset, so gating
187
+ * on it would freeze a partly-filled request in Processing forever. A global
188
+ * settlement clock is equally wrong here — it is blind to whether THIS
189
+ * request's pool is already clearing.
190
+ */
191
+ export function isCycleClosed(request: Pick<UserRequest, 'canCancel'>): boolean {
192
+ return !request.canCancel;
193
+ }
194
+
195
+ const isCancelled = (events: UserRequestEvent[]): boolean =>
196
+ events.some((e) => e.requestType === 'Cancelled');
197
+
198
+ const isForced = (events: UserRequestEvent[]): boolean =>
199
+ events.some((e) => e.requestType === 'Forced');
200
+
201
+ const initiatedEvent = (
202
+ events: UserRequestEvent[],
203
+ ): UserRequestEvent | undefined =>
204
+ events.find((e) => e.requestType === 'Initiated');
205
+
206
+ /**
207
+ * Subgraph behaviour: when a request is cancelled, the on-request
208
+ * `requestedAmount` field is reset to 0 (the lender's effective balance is
209
+ * restored). The original amount survives on the `Initiated` event's
210
+ * `assetAmount`. Recover from there so cancelled rows still carry the amount
211
+ * the lender originally asked for.
212
+ */
213
+ const initiatedAmountOf = (events: UserRequestEvent[]): number => {
214
+ const initiated = initiatedEvent(events);
215
+ return initiated ? Number(initiated.assetAmount || '0') : 0;
216
+ };
217
+
218
+ /**
219
+ * A deposit is REALLOCATED when the timeline carries a `Reallocated` event, or
220
+ * an `Accepted` event into a tranche other than the one requested.
221
+ */
222
+ const findReallocation = (
223
+ events: UserRequestEvent[],
224
+ originalTrancheId: string,
225
+ ): UserRequestEvent | undefined =>
226
+ events.find(
227
+ (e) =>
228
+ e.requestType === 'Reallocated' ||
229
+ (e.requestType === 'Accepted' &&
230
+ e.trancheId.toLowerCase() !==
231
+ originalTrancheId.toLowerCase()),
232
+ );
233
+
234
+ /** kasu-ui's `num`: an absent or unparseable figure reads as 0. */
235
+ const num = (str: string | undefined): number => {
236
+ const n = Number(str ?? '0');
237
+ return Number.isFinite(n) ? n : 0;
238
+ };
239
+
240
+ /** The same parse, keeping "the subgraph reported nothing" distinct from 0. */
241
+ const numOrNull = (str: string | null | undefined): number | null => {
242
+ if (str === undefined || str === null || str.trim() === '') return null;
243
+ const n = Number(str);
244
+ return Number.isFinite(n) ? n : null;
245
+ };
246
+
247
+ /**
248
+ * Convert a `UserRequest` into a `RequestState`. Pure — no clock, no network,
249
+ * no copy.
250
+ *
251
+ * BRANCH ORDER (this IS the behaviour; it reproduces kasu-ui's
252
+ * `deriveTransactionView` check for check):
253
+ *
254
+ * 1. a `Cancelled` event → `cancelled`, neutral, 0
255
+ * 2. a withdrawal with a `Forced` event → `forced`, outflow, −accepted
256
+ * 3. a reallocated deposit → `reallocated`, inflow, +accepted
257
+ * 4. a withdrawal partly filled with a LIVE
258
+ * remainder (cycle still open) → `partial`, outflow, −accepted
259
+ * 5. resolved (`status === 'Processed'`):
260
+ * withdrawal, partly filled → `partial`, outflow, −accepted
261
+ * withdrawal, fully filled → `complete`, outflow, −accepted
262
+ * deposit, nothing accepted → `rejected`, neutral, 0
263
+ * deposit, part rejected → `partial`, inflow, +accepted
264
+ * deposit, fully accepted → `complete`, inflow, +accepted
265
+ * 6. otherwise → `pending`, ±requested
266
+ *
267
+ * `cancelled` is reachable ONLY from branch 1 — a Cancelled EVENT. No
268
+ * processing or resolved state can derive it.
269
+ *
270
+ * Branch 4 is checked BEFORE the resolved branch and gates on `isCycleClosed`
271
+ * (i.e. `canCancel`), never on the sticky raw status: a withdrawal that was
272
+ * partly filled returns to the queue with a live Cancel, and reading the raw
273
+ * status would strand it.
274
+ *
275
+ * WHAT THE APPLICATION STILL OWNS: the status word and the detail line beneath
276
+ * it; the tranche display rename (`getTrancheDisplayName` on `trancheName`,
277
+ * and on the reallocation destination read off `request.events`); the pool-name
278
+ * split; the amount format. The "view loan agreement" affordance is a fact, and
279
+ * it follows from two fields already here —
280
+ * `requestType === 'Deposit' && statusCode !== 'cancelled' && statusCode !== 'rejected'`
281
+ * — because neither a cancelled nor a fully-rejected deposit ever issued one,
282
+ * and withdrawals sign no agreement at all.
283
+ */
284
+ export function deriveRequestState(request: UserRequest): RequestState {
285
+ const isWithdrawal = request.requestType === 'Withdrawal';
286
+ const cancelled = isCancelled(request.events);
287
+ // Cancelled requests have `requestedAmount` zeroed on the request itself;
288
+ // pull the original value from the Initiated event so the row still
289
+ // carries "100 cancelled" instead of "0 cancelled".
290
+ const requested = cancelled
291
+ ? num(request.requestedAmount) || initiatedAmountOf(request.events)
292
+ : num(request.requestedAmount);
293
+ const accepted = num(request.acceptedAmount);
294
+ const rejected = num(request.rejectedAmount);
295
+ const forced = isWithdrawal && isForced(request.events);
296
+ const reallocation =
297
+ !isWithdrawal && findReallocation(request.events, request.trancheId);
298
+
299
+ const initiated = initiatedEvent(request.events);
300
+
301
+ const base = {
302
+ id: request.id,
303
+ contractId: initiated?.id ?? '',
304
+ poolId: request.lendingPool.id.toLowerCase(),
305
+ poolName: request.lendingPool.name,
306
+ trancheName: request.trancheName,
307
+ trancheId: request.trancheId,
308
+ fixedTermConfigId: request.fixedTermConfig?.configId ?? '0',
309
+ requestType: request.requestType,
310
+ rawStatus: request.status,
311
+ requestedAmount: requested,
312
+ acceptedAmount: numOrNull(request.acceptedAmount),
313
+ initiatedAmount: initiated
314
+ ? initiatedAmountOf(request.events)
315
+ : null,
316
+ submissionCount: countSubmissions(request.events),
317
+ // `firstSubmissionTimestamp` needs a fallback it will never use here:
318
+ // the bundle is non-empty on every path that reaches the call.
319
+ firstSubmissionTimestamp:
320
+ countSubmissions(request.events) === 0
321
+ ? null
322
+ : firstSubmissionTimestamp(request.events, 0),
323
+ cycleClosed: isCycleClosed(request),
324
+ canCancel: request.canCancel,
325
+ };
326
+
327
+ // 1. Cancelled wins over everything — the ONLY path to `cancelled`.
328
+ if (cancelled) {
329
+ return { ...base, kind: 'neutral', amount: 0, statusCode: 'cancelled' };
330
+ }
331
+
332
+ // 2. Forced withdrawal (credit originator returned funds early).
333
+ if (forced) {
334
+ return {
335
+ ...base,
336
+ kind: 'outflow',
337
+ amount: -accepted,
338
+ statusCode: 'forced',
339
+ };
340
+ }
341
+
342
+ // 3. Reallocated deposit (accepted into a different lending option).
343
+ if (reallocation) {
344
+ return {
345
+ ...base,
346
+ kind: 'inflow',
347
+ amount: accepted,
348
+ statusCode: 'reallocated',
349
+ };
350
+ }
351
+
352
+ // 4. Withdrawal partly filled with a LIVE remainder: the request returns
353
+ // to the queue with a live Cancel. Checked BEFORE the resolved branch and
354
+ // via `isCycleClosed`, never via the sticky raw status.
355
+ if (
356
+ isWithdrawal &&
357
+ accepted > 0 &&
358
+ accepted < requested &&
359
+ !isCycleClosed(request)
360
+ ) {
361
+ return {
362
+ ...base,
363
+ kind: 'outflow',
364
+ amount: -accepted,
365
+ statusCode: 'partial',
366
+ };
367
+ }
368
+
369
+ // 5. Resolved — a rejection IS a resolution.
370
+ if (request.status === UserRequestStatus.PROCESSED) {
371
+ if (isWithdrawal) {
372
+ const partly = accepted < requested;
373
+ return {
374
+ ...base,
375
+ kind: 'outflow',
376
+ amount: -accepted,
377
+ statusCode: partly ? 'partial' : 'complete',
378
+ };
379
+ }
380
+
381
+ // Deposit: full reject vs partial vs full accept.
382
+ if (accepted === 0) {
383
+ return {
384
+ ...base,
385
+ kind: 'neutral',
386
+ amount: 0,
387
+ statusCode: 'rejected',
388
+ };
389
+ }
390
+ if (rejected > 0) {
391
+ return {
392
+ ...base,
393
+ kind: 'inflow',
394
+ amount: accepted,
395
+ statusCode: 'partial',
396
+ };
397
+ }
398
+ return {
399
+ ...base,
400
+ kind: 'inflow',
401
+ amount: accepted,
402
+ statusCode: 'complete',
403
+ };
404
+ }
405
+
406
+ // 6. Unresolved. `cycleClosed` tells the caller whether to render its
407
+ // "queued" or its "processing" word; the code is the same either way.
408
+ return {
409
+ ...base,
410
+ kind: isWithdrawal ? 'outflow' : 'inflow',
411
+ amount: isWithdrawal ? -requested : requested,
412
+ statusCode: 'pending',
413
+ };
414
+ }
@@ -0,0 +1,198 @@
1
+ import {
2
+ CLEARING_WINDOW_SECONDS,
3
+ computeSettlementWindow,
4
+ deriveCycleDates,
5
+ nextCycleBoundary,
6
+ } from './settlement';
7
+
8
+ /**
9
+ * Ported from kasu-ui `src/features/portfolio/lib/settlement-window.test.ts`
10
+ * and the non-format half of
11
+ * `src/features/lending/lib/cycle-dates.test.ts`. The `formatCycleDate` /
12
+ * `formatCycleCloseUtc` cases stay there — they assert printed words.
13
+ */
14
+
15
+ const HOUR = 60 * 60;
16
+ const DAY = 24 * HOUR;
17
+ const CLEARING = 48 * HOUR;
18
+ const WEEK = 7 * DAY;
19
+
20
+ describe('computeSettlementWindow', () => {
21
+ it('returns "unknown" when the epoch boundary is missing', () => {
22
+ expect(
23
+ computeSettlementWindow({
24
+ nowSeconds: 1_000_000,
25
+ nextEpochStart: 0,
26
+ }),
27
+ ).toEqual({ phase: 'unknown' });
28
+ });
29
+
30
+ it('returns "unknown" when the epoch boundary is stale (already elapsed)', () => {
31
+ expect(
32
+ computeSettlementWindow({
33
+ nowSeconds: 1_000_000,
34
+ nextEpochStart: 999_000,
35
+ }),
36
+ ).toEqual({ phase: 'unknown' });
37
+ });
38
+
39
+ it('counts down to the clearing window when more than 48h from epoch end', () => {
40
+ const epochEnd = 1_700_000_000; // Thu 06:00 UTC
41
+ const now = epochEnd - 3 * DAY; // 24h before the window opens
42
+ const result = computeSettlementWindow({
43
+ nowSeconds: now,
44
+ nextEpochStart: epochEnd,
45
+ });
46
+
47
+ expect(result.phase).toBe('awaiting');
48
+ if (result.phase !== 'awaiting') return;
49
+ expect(result.nextClearingStart).toBe(epochEnd - CLEARING); // Tue 06:00
50
+ expect(result.secondsUntilClearing).toBe(DAY);
51
+ });
52
+
53
+ it('reports "clearing" only inside the 48h window before epoch end', () => {
54
+ const epochEnd = 1_700_000_000;
55
+ const now = epochEnd - 24 * HOUR; // 24h into the window
56
+ const result = computeSettlementWindow({
57
+ nowSeconds: now,
58
+ nextEpochStart: epochEnd,
59
+ });
60
+
61
+ expect(result.phase).toBe('clearing');
62
+ if (result.phase !== 'clearing') return;
63
+ expect(result.epochEnd).toBe(epochEnd);
64
+ expect(result.secondsUntilEpochEnd).toBe(24 * HOUR);
65
+ });
66
+
67
+ it('flips awaiting → clearing exactly at the window boundary (T-48h)', () => {
68
+ const epochEnd = 1_700_000_000;
69
+ expect(
70
+ computeSettlementWindow({
71
+ nowSeconds: epochEnd - CLEARING - 1,
72
+ nextEpochStart: epochEnd,
73
+ }).phase,
74
+ ).toBe('awaiting');
75
+ expect(
76
+ computeSettlementWindow({
77
+ nowSeconds: epochEnd - CLEARING,
78
+ nextEpochStart: epochEnd,
79
+ }).phase,
80
+ ).toBe('clearing');
81
+ });
82
+
83
+ it('respects a non-default clearing window length', () => {
84
+ const epochEnd = 1_700_000_000;
85
+ const now = epochEnd - 36 * HOUR;
86
+ // 36h out is "clearing" under the default 48h window but "awaiting"
87
+ // under a 24h one.
88
+ expect(
89
+ computeSettlementWindow({
90
+ nowSeconds: now,
91
+ nextEpochStart: epochEnd,
92
+ }).phase,
93
+ ).toBe('clearing');
94
+ expect(
95
+ computeSettlementWindow({
96
+ nowSeconds: now,
97
+ nextEpochStart: epochEnd,
98
+ clearingWindowSeconds: 24 * HOUR,
99
+ }).phase,
100
+ ).toBe('awaiting');
101
+ });
102
+ });
103
+
104
+ describe('nextCycleBoundary', () => {
105
+ const epochEnd = 1_700_000_000;
106
+ const clearingStart = epochEnd - CLEARING;
107
+
108
+ it('returns the cycle close while the window is still ahead', () => {
109
+ expect(nextCycleBoundary(epochEnd, epochEnd - 3 * DAY)).toBe(
110
+ clearingStart,
111
+ );
112
+ });
113
+
114
+ it('returns the epoch end once inside the clearing window', () => {
115
+ expect(nextCycleBoundary(epochEnd, clearingStart + 1)).toBe(epochEnd);
116
+ });
117
+
118
+ it('hands over from close to epoch end exactly at the boundary instant', () => {
119
+ // At T-48h the close has just been reached, so the next thing to wait
120
+ // for is the epoch end — not the instant we are standing on.
121
+ expect(nextCycleBoundary(epochEnd, clearingStart - 1)).toBe(
122
+ clearingStart,
123
+ );
124
+ expect(nextCycleBoundary(epochEnd, clearingStart)).toBe(epochEnd);
125
+ });
126
+
127
+ it('returns undefined when there is nothing left to wait for', () => {
128
+ expect(nextCycleBoundary(epochEnd, epochEnd)).toBeUndefined(); // elapsed
129
+ expect(nextCycleBoundary(epochEnd, epochEnd + DAY)).toBeUndefined(); // stale
130
+ expect(nextCycleBoundary(undefined, epochEnd - DAY)).toBeUndefined(); // not loaded
131
+ expect(nextCycleBoundary(0, epochEnd - DAY)).toBeUndefined(); // sentinel zero
132
+ });
133
+
134
+ it('agrees with computeSettlementWindow about where the window opens', () => {
135
+ // One number, two consumers: the phase machine and the timer helper
136
+ // must never disagree about when the cycle turns.
137
+ expect(CLEARING_WINDOW_SECONDS).toBe(CLEARING);
138
+ const justInside = nextCycleBoundary(epochEnd, clearingStart);
139
+ expect(
140
+ computeSettlementWindow({
141
+ nowSeconds: clearingStart,
142
+ nextEpochStart: epochEnd,
143
+ }).phase,
144
+ ).toBe('clearing');
145
+ expect(justInside).toBe(epochEnd);
146
+ });
147
+
148
+ it('respects a non-default clearing window length', () => {
149
+ const now = epochEnd - 36 * HOUR;
150
+ // 36h out: inside a 48h window (next stop is the epoch end), still
151
+ // ahead of a 24h one (next stop is that window opening).
152
+ expect(nextCycleBoundary(epochEnd, now)).toBe(epochEnd);
153
+ expect(nextCycleBoundary(epochEnd, now, 24 * HOUR)).toBe(
154
+ epochEnd - 24 * HOUR,
155
+ );
156
+ });
157
+ });
158
+
159
+ describe('deriveCycleDates', () => {
160
+ // Fixture: epoch end = Thu 6 Aug 2026, 06:00 UTC.
161
+ const EPOCH_END = Date.UTC(2026, 7, 6, 6, 0, 0) / 1000; // 1785996000
162
+ const CLOSE = EPOCH_END - CLEARING; // Tue 4 Aug 06:00 UTC
163
+
164
+ it('returns close = epochEnd − 48h and outcome = epochEnd before the window opens', () => {
165
+ const now = Date.UTC(2026, 7, 1, 12, 0, 0) / 1000; // Sat 1 Aug
166
+ expect(deriveCycleDates(EPOCH_END, now)).toEqual({
167
+ close: CLOSE,
168
+ outcome: EPOCH_END,
169
+ });
170
+ });
171
+
172
+ it('advances to the next weekly cycle when the request lands inside the clearing window', () => {
173
+ // now is Wed 5 Aug 00:00 — inside [close, epochEnd): this cycle has
174
+ // already closed, so the request queues for next week.
175
+ const now = Date.UTC(2026, 7, 5, 0, 0, 0) / 1000;
176
+ expect(deriveCycleDates(EPOCH_END, now)).toEqual({
177
+ close: CLOSE + WEEK,
178
+ outcome: EPOCH_END + WEEK,
179
+ });
180
+ });
181
+
182
+ it('returns null when the epoch boundary is missing', () => {
183
+ expect(deriveCycleDates(undefined, EPOCH_END - WEEK)).toBeNull();
184
+ expect(deriveCycleDates(0, EPOCH_END - WEEK)).toBeNull();
185
+ });
186
+
187
+ it('returns null when the epoch boundary is stale (already elapsed)', () => {
188
+ expect(deriveCycleDates(EPOCH_END, EPOCH_END + 60)).toBeNull();
189
+ });
190
+
191
+ it('runs on the same window constant as the phase machine', () => {
192
+ const now = Date.UTC(2026, 7, 1, 12, 0, 0) / 1000;
193
+ const dates = deriveCycleDates(EPOCH_END, now);
194
+ expect(dates?.outcome).toBe(
195
+ (dates?.close ?? 0) + CLEARING_WINDOW_SECONDS,
196
+ );
197
+ });
198
+ });