@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,653 @@
1
+ import { UserRequestStatus } from '../services/UserLending/subgraph-types';
2
+ import {
3
+ UserRequest,
4
+ UserRequestEvent,
5
+ } from '../services/UserLending/types';
6
+
7
+ import {
8
+ countSubmissions,
9
+ deriveRequestState,
10
+ firstSubmissionTimestamp,
11
+ isCycleClosed,
12
+ submissionEvents,
13
+ } from './requests';
14
+
15
+ /**
16
+ * Ported from kasu-ui `src/features/portfolio/lib/derive-transaction-view.test.ts`.
17
+ * The status-word, detail-line, tranche-rename and `splitPoolName` cases stay
18
+ * there — they assert copy and display, which this layer does not own. What
19
+ * comes across is every assertion about codes, kind, amounts, ids and counts,
20
+ * because those are the branch behaviour.
21
+ */
22
+
23
+ const TRANCHE_ID = '0xtranche-senior';
24
+ const OTHER_TRANCHE_ID = '0xtranche-mezzanine';
25
+ const POOL_ID = '0xPool-PayFi';
26
+ const POOL_NAME = 'Payment Finance (PayFi) - Payment Clearing Houses';
27
+
28
+ const REQUESTED = UserRequestStatus.REQUESTED;
29
+ const PROCESSING = UserRequestStatus.PROCESSING;
30
+ const PROCESSED = UserRequestStatus.PROCESSED;
31
+
32
+ function makeRequest(overrides: Partial<UserRequest> = {}): UserRequest {
33
+ return {
34
+ id: 'req-1',
35
+ userId: '0xuser',
36
+ lendingPool: {
37
+ id: POOL_ID,
38
+ name: POOL_NAME,
39
+ tranches: [{ orderId: '0' }],
40
+ },
41
+ requestType: 'Deposit',
42
+ trancheId: TRANCHE_ID,
43
+ trancheName: 'Senior',
44
+ requestedAmount: '100',
45
+ acceptedAmount: '0',
46
+ rejectedAmount: '0',
47
+ timestamp: 1_700_000_000,
48
+ status: REQUESTED,
49
+ canCancel: true,
50
+ events: [],
51
+ nftId: '',
52
+ apy: '0',
53
+ fixedTermConfig: undefined,
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ function event(partial: Partial<UserRequestEvent>): UserRequestEvent {
59
+ return {
60
+ id: 'evt',
61
+ requestType: 'Initiated',
62
+ assetAmount: '0',
63
+ totalRequested: '0',
64
+ totalAccepted: '0',
65
+ totalRejected: '0',
66
+ index: 0,
67
+ timestamp: 1_700_000_000,
68
+ transactionHash: '0x',
69
+ trancheName: 'Senior',
70
+ trancheId: TRANCHE_ID,
71
+ apy: '0',
72
+ epochId: '0',
73
+ ...partial,
74
+ };
75
+ }
76
+
77
+ describe('deriveRequestState — every branch of the derivation', () => {
78
+ it('deposit just submitted, cycle open → pending · inflow · +requested', () => {
79
+ const state = deriveRequestState(makeRequest());
80
+ expect(state.statusCode).toBe('pending');
81
+ expect(state.kind).toBe('inflow');
82
+ expect(state.amount).toBe(100);
83
+ expect(state.cycleClosed).toBe(false);
84
+ expect(state.canCancel).toBe(true);
85
+ });
86
+
87
+ it('deposit while its pool is clearing (canCancel false) → pending, cycle CLOSED', () => {
88
+ const state = deriveRequestState(makeRequest({ canCancel: false }));
89
+ expect(state.statusCode).toBe('pending');
90
+ expect(state.cycleClosed).toBe(true);
91
+ });
92
+
93
+ it('a STICKY Processing raw status with an open cycle stays pending and open', () => {
94
+ // The subgraph never resets `status` after a partial fill — `canCancel`
95
+ // alone decides open/closed.
96
+ const state = deriveRequestState(
97
+ makeRequest({ status: PROCESSING, canCancel: true }),
98
+ );
99
+ expect(state.statusCode).toBe('pending');
100
+ expect(state.cycleClosed).toBe(false);
101
+ expect(state.rawStatus).toBe(PROCESSING);
102
+ });
103
+
104
+ it('deposit fully accepted → complete · inflow · +accepted', () => {
105
+ const state = deriveRequestState(
106
+ makeRequest({
107
+ status: PROCESSED,
108
+ canCancel: false,
109
+ acceptedAmount: '100',
110
+ }),
111
+ );
112
+ expect(state.statusCode).toBe('complete');
113
+ expect(state.kind).toBe('inflow');
114
+ expect(state.amount).toBe(100);
115
+ });
116
+
117
+ it('deposit partially accepted → partial · inflow · the accepted figure', () => {
118
+ const state = deriveRequestState(
119
+ makeRequest({
120
+ status: PROCESSED,
121
+ canCancel: false,
122
+ acceptedAmount: '60',
123
+ rejectedAmount: '40',
124
+ }),
125
+ );
126
+ expect(state.statusCode).toBe('partial');
127
+ expect(state.kind).toBe('inflow');
128
+ expect(state.amount).toBe(60);
129
+ expect(state.requestedAmount).toBe(100);
130
+ expect(state.acceptedAmount).toBe(60);
131
+ });
132
+
133
+ it('deposit fully rejected → rejected · neutral · 0 — a rejection IS a resolution', () => {
134
+ const state = deriveRequestState(
135
+ makeRequest({
136
+ status: PROCESSED,
137
+ canCancel: false,
138
+ rejectedAmount: '100',
139
+ }),
140
+ );
141
+ expect(state.statusCode).toBe('rejected');
142
+ expect(state.kind).toBe('neutral');
143
+ expect(state.amount).toBe(0);
144
+ });
145
+
146
+ it('deposit cancelled → cancelled · neutral · 0', () => {
147
+ const state = deriveRequestState(
148
+ makeRequest({ events: [event({ requestType: 'Cancelled' })] }),
149
+ );
150
+ expect(state.statusCode).toBe('cancelled');
151
+ expect(state.kind).toBe('neutral');
152
+ expect(state.amount).toBe(0);
153
+ });
154
+
155
+ it('a Cancelled event outranks a resolved status', () => {
156
+ const state = deriveRequestState(
157
+ makeRequest({
158
+ status: PROCESSED,
159
+ canCancel: false,
160
+ acceptedAmount: '100',
161
+ events: [event({ requestType: 'Cancelled' })],
162
+ }),
163
+ );
164
+ expect(state.statusCode).toBe('cancelled');
165
+ expect(state.amount).toBe(0);
166
+ });
167
+
168
+ it('deposit reallocated (Accepted into another tranche) → reallocated · inflow', () => {
169
+ const state = deriveRequestState(
170
+ makeRequest({
171
+ status: PROCESSED,
172
+ canCancel: false,
173
+ acceptedAmount: '100',
174
+ events: [
175
+ event({
176
+ requestType: 'Accepted',
177
+ trancheId: OTHER_TRANCHE_ID,
178
+ trancheName: 'Mezzanine',
179
+ assetAmount: '100',
180
+ }),
181
+ ],
182
+ }),
183
+ );
184
+ expect(state.statusCode).toBe('reallocated');
185
+ expect(state.kind).toBe('inflow');
186
+ expect(state.amount).toBe(100);
187
+ });
188
+
189
+ it('an Accepted event into the SAME tranche is not a reallocation', () => {
190
+ const state = deriveRequestState(
191
+ makeRequest({
192
+ status: PROCESSED,
193
+ canCancel: false,
194
+ acceptedAmount: '100',
195
+ events: [
196
+ event({
197
+ requestType: 'Accepted',
198
+ trancheId: TRANCHE_ID.toUpperCase(),
199
+ assetAmount: '100',
200
+ }),
201
+ ],
202
+ }),
203
+ );
204
+ // Tranche ids are compared case-insensitively, so an upper-cased
205
+ // spelling of the same tranche must not read as a move.
206
+ expect(state.statusCode).toBe('complete');
207
+ });
208
+
209
+ it('an explicit Reallocated event is a reallocation whatever the tranche', () => {
210
+ const state = deriveRequestState(
211
+ makeRequest({
212
+ status: PROCESSED,
213
+ canCancel: false,
214
+ acceptedAmount: '100',
215
+ events: [
216
+ event({
217
+ requestType: 'Reallocated',
218
+ assetAmount: '100',
219
+ }),
220
+ ],
221
+ }),
222
+ );
223
+ expect(state.statusCode).toBe('reallocated');
224
+ });
225
+
226
+ it('a withdrawal is never reallocated, whatever its events say', () => {
227
+ const state = deriveRequestState(
228
+ makeRequest({
229
+ requestType: 'Withdrawal',
230
+ status: PROCESSED,
231
+ canCancel: false,
232
+ acceptedAmount: '100',
233
+ events: [
234
+ event({
235
+ requestType: 'Accepted',
236
+ trancheId: OTHER_TRANCHE_ID,
237
+ assetAmount: '100',
238
+ }),
239
+ ],
240
+ }),
241
+ );
242
+ expect(state.statusCode).toBe('complete');
243
+ expect(state.kind).toBe('outflow');
244
+ });
245
+
246
+ it('withdrawal submitted, cycle open → pending · outflow · −requested', () => {
247
+ const state = deriveRequestState(
248
+ makeRequest({ requestType: 'Withdrawal' }),
249
+ );
250
+ expect(state.statusCode).toBe('pending');
251
+ expect(state.kind).toBe('outflow');
252
+ expect(state.amount).toBe(-100);
253
+ });
254
+
255
+ it('withdrawal fully filled → complete · −accepted', () => {
256
+ const state = deriveRequestState(
257
+ makeRequest({
258
+ requestType: 'Withdrawal',
259
+ status: PROCESSED,
260
+ canCancel: false,
261
+ acceptedAmount: '100',
262
+ }),
263
+ );
264
+ expect(state.statusCode).toBe('complete');
265
+ expect(state.amount).toBe(-100);
266
+ });
267
+
268
+ it('withdrawal partly filled with a LIVE remainder → partial while the cycle is open', () => {
269
+ // Realistic shape: the sticky raw status reads Processing, canCancel
270
+ // marks the new cycle open — the request returns to the queue with a
271
+ // live Cancel.
272
+ const state = deriveRequestState(
273
+ makeRequest({
274
+ requestType: 'Withdrawal',
275
+ status: PROCESSING,
276
+ canCancel: true,
277
+ acceptedAmount: '60',
278
+ }),
279
+ );
280
+ expect(state.statusCode).toBe('partial');
281
+ expect(state.kind).toBe('outflow');
282
+ expect(state.amount).toBe(-60);
283
+ expect(state.cycleClosed).toBe(false);
284
+ });
285
+
286
+ it('withdrawal partly filled, pool CLEARING → pending with the cycle closed (locked remainder)', () => {
287
+ const state = deriveRequestState(
288
+ makeRequest({
289
+ requestType: 'Withdrawal',
290
+ status: PROCESSING,
291
+ canCancel: false,
292
+ acceptedAmount: '60',
293
+ }),
294
+ );
295
+ expect(state.statusCode).toBe('pending');
296
+ expect(state.cycleClosed).toBe(true);
297
+ expect(state.amount).toBe(-100);
298
+ });
299
+
300
+ it('withdrawal partly filled, TERMINAL → partial', () => {
301
+ const state = deriveRequestState(
302
+ makeRequest({
303
+ requestType: 'Withdrawal',
304
+ status: PROCESSED,
305
+ canCancel: false,
306
+ acceptedAmount: '60',
307
+ }),
308
+ );
309
+ expect(state.statusCode).toBe('partial');
310
+ expect(state.amount).toBe(-60);
311
+ });
312
+
313
+ it('forced withdrawal → forced · outflow · −accepted', () => {
314
+ const state = deriveRequestState(
315
+ makeRequest({
316
+ requestType: 'Withdrawal',
317
+ status: PROCESSED,
318
+ canCancel: false,
319
+ acceptedAmount: '80',
320
+ events: [event({ requestType: 'Forced' })],
321
+ }),
322
+ );
323
+ expect(state.statusCode).toBe('forced');
324
+ expect(state.kind).toBe('outflow');
325
+ expect(state.amount).toBe(-80);
326
+ });
327
+
328
+ it('a Forced event on a DEPOSIT is not a forced withdrawal', () => {
329
+ const state = deriveRequestState(
330
+ makeRequest({
331
+ status: PROCESSED,
332
+ canCancel: false,
333
+ acceptedAmount: '80',
334
+ events: [event({ requestType: 'Forced' })],
335
+ }),
336
+ );
337
+ expect(state.statusCode).toBe('complete');
338
+ expect(state.kind).toBe('inflow');
339
+ });
340
+
341
+ it('withdrawal cancelled → cancelled · neutral · 0', () => {
342
+ const state = deriveRequestState(
343
+ makeRequest({
344
+ requestType: 'Withdrawal',
345
+ events: [event({ requestType: 'Cancelled' })],
346
+ }),
347
+ );
348
+ expect(state.statusCode).toBe('cancelled');
349
+ expect(state.kind).toBe('neutral');
350
+ expect(state.amount).toBe(0);
351
+ });
352
+ });
353
+
354
+ describe('deriveRequestState — the cancelled-amount recovery', () => {
355
+ it('recovers a zeroed requestedAmount from the Initiated event', () => {
356
+ const state = deriveRequestState(
357
+ makeRequest({
358
+ requestedAmount: '0',
359
+ events: [
360
+ event({ requestType: 'Initiated', assetAmount: '250' }),
361
+ event({ requestType: 'Cancelled', index: 1 }),
362
+ ],
363
+ }),
364
+ );
365
+ expect(state.requestedAmount).toBe(250);
366
+ expect(state.initiatedAmount).toBe(250);
367
+ });
368
+
369
+ it('falls back to 0 when the cancelled request has no Initiated event', () => {
370
+ const state = deriveRequestState(
371
+ makeRequest({
372
+ requestedAmount: '0',
373
+ events: [event({ requestType: 'Cancelled' })],
374
+ }),
375
+ );
376
+ expect(state.requestedAmount).toBe(0);
377
+ expect(state.initiatedAmount).toBeNull();
378
+ });
379
+
380
+ it('does not recover for a request that was NOT cancelled', () => {
381
+ // A live request with a genuinely zero requested amount keeps it; only
382
+ // the cancelled branch reaches for the Initiated event.
383
+ const state = deriveRequestState(
384
+ makeRequest({
385
+ requestedAmount: '0',
386
+ events: [
387
+ event({ requestType: 'Initiated', assetAmount: '250' }),
388
+ ],
389
+ }),
390
+ );
391
+ expect(state.requestedAmount).toBe(0);
392
+ expect(state.initiatedAmount).toBe(250);
393
+ });
394
+ });
395
+
396
+ describe('deriveRequestState — ids, counts and pass-through fields', () => {
397
+ it('takes contractId from the Initiated EVENT, not the request id', () => {
398
+ const state = deriveRequestState(
399
+ makeRequest({
400
+ events: [
401
+ event({ requestType: 'Initiated', id: 'req-1-0' }),
402
+ event({
403
+ requestType: 'Increased',
404
+ id: 'req-1-1',
405
+ index: 1,
406
+ }),
407
+ ],
408
+ }),
409
+ );
410
+ expect(state.contractId).toBe('req-1-0');
411
+ expect(state.id).toBe('req-1');
412
+ });
413
+
414
+ it('leaves contractId empty when nothing was Initiated', () => {
415
+ expect(deriveRequestState(makeRequest()).contractId).toBe('');
416
+ });
417
+
418
+ it('lowercases the pool id and leaves the pool name raw', () => {
419
+ const state = deriveRequestState(makeRequest());
420
+ expect(state.poolId).toBe(POOL_ID.toLowerCase());
421
+ expect(state.poolName).toBe(POOL_NAME);
422
+ });
423
+
424
+ it('leaves the tranche name RAW — the display rename is the app’s', () => {
425
+ // An Apxium pool: kasu-ui shows "Upper Mezzanine" here, this layer must
426
+ // not, or a renamed name would reach matching code.
427
+ const state = deriveRequestState(
428
+ makeRequest({
429
+ lendingPool: {
430
+ id: '0xpool-apxium',
431
+ name: 'Taxation Funding (Tax Pay) - Diversified Businesses',
432
+ tranches: [{ orderId: '0' }],
433
+ },
434
+ }),
435
+ );
436
+ expect(state.trancheName).toBe('Senior');
437
+ });
438
+
439
+ it('defaults fixedTermConfigId to "0" for a variable-rate request', () => {
440
+ expect(deriveRequestState(makeRequest()).fixedTermConfigId).toBe('0');
441
+ });
442
+
443
+ it('passes a fixed-term configId through', () => {
444
+ const state = deriveRequestState(
445
+ makeRequest({
446
+ fixedTermConfig: {
447
+ configId: '7',
448
+ apy: '0.1',
449
+ epochLockDuration: '4',
450
+ epochInterestRate: '0.002',
451
+ },
452
+ }),
453
+ );
454
+ expect(state.fixedTermConfigId).toBe('7');
455
+ });
456
+
457
+ it('counts only submissions into submissionCount and takes the earliest one', () => {
458
+ const state = deriveRequestState(
459
+ makeRequest({
460
+ events: [
461
+ event({
462
+ requestType: 'Initiated',
463
+ timestamp: 1_700_000_500,
464
+ }),
465
+ event({
466
+ requestType: 'Increased',
467
+ timestamp: 1_700_000_100,
468
+ index: 1,
469
+ }),
470
+ event({
471
+ requestType: 'Accepted',
472
+ timestamp: 1_700_000_900,
473
+ index: 2,
474
+ }),
475
+ ],
476
+ }),
477
+ );
478
+ expect(state.submissionCount).toBe(2);
479
+ expect(state.firstSubmissionTimestamp).toBe(1_700_000_100);
480
+ });
481
+
482
+ it('reports a null first-submission timestamp before the Initiated event indexes', () => {
483
+ const state = deriveRequestState(makeRequest());
484
+ expect(state.submissionCount).toBe(0);
485
+ expect(state.firstSubmissionTimestamp).toBeNull();
486
+ });
487
+
488
+ it('keeps "the subgraph reported nothing" distinct from a reported zero', () => {
489
+ expect(deriveRequestState(makeRequest()).acceptedAmount).toBe(0);
490
+ expect(
491
+ deriveRequestState(makeRequest({ acceptedAmount: '' }))
492
+ .acceptedAmount,
493
+ ).toBeNull();
494
+ expect(
495
+ deriveRequestState(
496
+ makeRequest({
497
+ acceptedAmount: undefined as unknown as string,
498
+ }),
499
+ ).acceptedAmount,
500
+ ).toBeNull();
501
+ });
502
+ });
503
+
504
+ /**
505
+ * The transition rule: `cancelled` is reachable only from a Cancelled EVENT,
506
+ * and every derivation lands on exactly one of the seven codes.
507
+ */
508
+ describe('deriveRequestState — permitted transitions', () => {
509
+ const FIXTURES: Partial<UserRequest>[] = [
510
+ {},
511
+ { canCancel: false },
512
+ { status: PROCESSING, canCancel: true },
513
+ { status: PROCESSED, canCancel: false, acceptedAmount: '100' },
514
+ { status: PROCESSED, canCancel: false, rejectedAmount: '100' },
515
+ {
516
+ requestType: 'Withdrawal',
517
+ status: PROCESSING,
518
+ canCancel: true,
519
+ acceptedAmount: '60',
520
+ },
521
+ {
522
+ requestType: 'Withdrawal',
523
+ status: PROCESSED,
524
+ canCancel: false,
525
+ acceptedAmount: '60',
526
+ },
527
+ { events: [event({ requestType: 'Cancelled' })] },
528
+ ];
529
+
530
+ const CODES = [
531
+ 'pending',
532
+ 'complete',
533
+ 'partial',
534
+ 'rejected',
535
+ 'reallocated',
536
+ 'cancelled',
537
+ 'forced',
538
+ ];
539
+
540
+ it('every derivation lands on exactly one known code', () => {
541
+ for (const overrides of FIXTURES) {
542
+ expect(CODES).toContain(
543
+ deriveRequestState(makeRequest(overrides)).statusCode,
544
+ );
545
+ }
546
+ });
547
+
548
+ it('cancelled derives ONLY from a Cancelled event', () => {
549
+ for (const overrides of FIXTURES) {
550
+ const hasCancelEvent = (overrides.events ?? []).some(
551
+ (e) => e.requestType === 'Cancelled',
552
+ );
553
+ const state = deriveRequestState(makeRequest(overrides));
554
+ expect(state.statusCode === 'cancelled').toBe(hasCancelEvent);
555
+ }
556
+ });
557
+
558
+ it('the loan-agreement affordance follows from requestType + statusCode', () => {
559
+ // kasu-ui's `canViewContract`, reconstructed from the two fields this
560
+ // layer exposes: deposits have an agreement unless nothing was ever
561
+ // issued (cancelled / fully rejected); withdrawals sign none.
562
+ const canView = (s: {
563
+ requestType: string;
564
+ statusCode: string;
565
+ }): boolean =>
566
+ s.requestType === 'Deposit' &&
567
+ s.statusCode !== 'cancelled' &&
568
+ s.statusCode !== 'rejected';
569
+
570
+ expect(canView(deriveRequestState(makeRequest()))).toBe(true);
571
+ expect(
572
+ canView(
573
+ deriveRequestState(
574
+ makeRequest({ events: [event({ requestType: 'Cancelled' })] }),
575
+ ),
576
+ ),
577
+ ).toBe(false);
578
+ expect(
579
+ canView(
580
+ deriveRequestState(
581
+ makeRequest({
582
+ status: PROCESSED,
583
+ canCancel: false,
584
+ rejectedAmount: '100',
585
+ }),
586
+ ),
587
+ ),
588
+ ).toBe(false);
589
+ expect(
590
+ canView(
591
+ deriveRequestState(
592
+ makeRequest({ requestType: 'Withdrawal' }),
593
+ ),
594
+ ),
595
+ ).toBe(false);
596
+ });
597
+ });
598
+
599
+ describe('submissionEvents / countSubmissions', () => {
600
+ const events = [
601
+ event({ requestType: 'Initiated' }),
602
+ event({ requestType: 'Accepted', index: 1 }),
603
+ event({ requestType: 'Increased', index: 2 }),
604
+ event({ requestType: 'Rejected', index: 3 }),
605
+ event({ requestType: 'Cancelled', index: 4 }),
606
+ event({ requestType: 'Reallocated', index: 5 }),
607
+ event({ requestType: 'Forced', index: 6 }),
608
+ ];
609
+
610
+ it('keeps only Initiated + Increased, in input order', () => {
611
+ expect(submissionEvents(events).map((e) => e.requestType)).toEqual([
612
+ 'Initiated',
613
+ 'Increased',
614
+ ]);
615
+ });
616
+
617
+ it('counts them', () => {
618
+ expect(countSubmissions(events)).toBe(2);
619
+ expect(countSubmissions([])).toBe(0);
620
+ });
621
+ });
622
+
623
+ describe('firstSubmissionTimestamp', () => {
624
+ it('returns the earliest submission timestamp, ignoring outcome events', () => {
625
+ expect(
626
+ firstSubmissionTimestamp(
627
+ [
628
+ event({ requestType: 'Initiated', timestamp: 300 }),
629
+ event({ requestType: 'Increased', timestamp: 200 }),
630
+ event({ requestType: 'Accepted', timestamp: 100 }),
631
+ ],
632
+ 999,
633
+ ),
634
+ ).toBe(200);
635
+ });
636
+
637
+ it('falls back when the bundle carries no submission yet', () => {
638
+ expect(firstSubmissionTimestamp([], 42)).toBe(42);
639
+ expect(
640
+ firstSubmissionTimestamp(
641
+ [event({ requestType: 'Accepted', timestamp: 1 })],
642
+ 42,
643
+ ),
644
+ ).toBe(42);
645
+ });
646
+ });
647
+
648
+ describe('isCycleClosed', () => {
649
+ it('is the inverse of the SDK per-pool cancel signal', () => {
650
+ expect(isCycleClosed({ canCancel: true })).toBe(false);
651
+ expect(isCycleClosed({ canCancel: false })).toBe(true);
652
+ });
653
+ });