@kasufinance/kasu-sdk 2.5.0 → 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 (72) hide show
  1. package/README.md +17 -5
  2. package/dist/bundle.cjs.js +797 -16
  3. package/dist/bundle.esm.js +771 -17
  4. package/dist/domain/au-minimum.d.ts +135 -0
  5. package/dist/domain/au-minimum.js +154 -0
  6. package/dist/domain/au-minimum.js.map +1 -0
  7. package/dist/domain/au-minimum.test.d.ts +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/index.d.ts +17 -3
  11. package/dist/domain/index.js +13 -3
  12. package/dist/domain/index.js.map +1 -1
  13. package/dist/domain/loan-contract.d.ts +174 -0
  14. package/dist/domain/loan-contract.js +160 -0
  15. package/dist/domain/loan-contract.js.map +1 -0
  16. package/dist/domain/loan-contract.test.d.ts +1 -0
  17. package/dist/domain/loan-contract.test.js +255 -0
  18. package/dist/domain/loan-contract.test.js.map +1 -0
  19. package/dist/domain/requests.d.ts +181 -0
  20. package/dist/domain/requests.js +202 -0
  21. package/dist/domain/requests.js.map +1 -0
  22. package/dist/domain/requests.test.d.ts +1 -0
  23. package/dist/domain/requests.test.js +470 -0
  24. package/dist/domain/requests.test.js.map +1 -0
  25. package/dist/domain/settlement.d.ts +97 -0
  26. package/dist/domain/settlement.js +117 -0
  27. package/dist/domain/settlement.js.map +1 -0
  28. package/dist/domain/settlement.test.d.ts +1 -0
  29. package/dist/domain/settlement.test.js +152 -0
  30. package/dist/domain/settlement.test.js.map +1 -0
  31. package/dist/domain/wallet-errors.d.ts +37 -0
  32. package/dist/domain/wallet-errors.js +56 -0
  33. package/dist/domain/wallet-errors.js.map +1 -0
  34. package/dist/domain/wallet-errors.test.d.ts +1 -0
  35. package/dist/domain/wallet-errors.test.js +71 -0
  36. package/dist/domain/wallet-errors.test.js.map +1 -0
  37. package/dist/facade/chain-configs.js +7 -1
  38. package/dist/facade/chain-configs.js.map +1 -1
  39. package/dist/facade/facade.test.js +82 -5
  40. package/dist/facade/facade.test.js.map +1 -1
  41. package/dist/facade/user-portfolio.d.ts +18 -0
  42. package/dist/facade/user-portfolio.js +23 -0
  43. package/dist/facade/user-portfolio.js.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +1 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/services/DataService/data-service.js +3 -10
  48. package/dist/services/DataService/data-service.js.map +1 -1
  49. package/dist/services/DataService/directus-client.d.ts +26 -0
  50. package/dist/services/DataService/directus-client.js +38 -0
  51. package/dist/services/DataService/directus-client.js.map +1 -0
  52. package/dist/services/UserLending/user-lending.js +11 -7
  53. package/dist/services/UserLending/user-lending.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/domain/au-minimum.test.ts +371 -0
  56. package/src/domain/au-minimum.ts +192 -0
  57. package/src/domain/index.ts +67 -3
  58. package/src/domain/loan-contract.test.ts +343 -0
  59. package/src/domain/loan-contract.ts +275 -0
  60. package/src/domain/requests.test.ts +653 -0
  61. package/src/domain/requests.ts +414 -0
  62. package/src/domain/settlement.test.ts +198 -0
  63. package/src/domain/settlement.ts +161 -0
  64. package/src/domain/wallet-errors.test.ts +100 -0
  65. package/src/domain/wallet-errors.ts +56 -0
  66. package/src/facade/chain-configs.ts +7 -1
  67. package/src/facade/facade.test.ts +124 -0
  68. package/src/facade/user-portfolio.ts +24 -0
  69. package/src/index.ts +2 -0
  70. package/src/services/DataService/data-service.ts +7 -24
  71. package/src/services/DataService/directus-client.ts +54 -0
  72. package/src/services/UserLending/user-lending.ts +17 -21
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Cycles and the clearing window — the protocol's weekly clock, as numbers.
3
+ *
4
+ * Lifted verbatim from kasu-ui's `features/portfolio/lib/settlement-window.ts`
5
+ * and `features/lending/lib/cycle-dates.ts`. The two formatters that live
6
+ * beside `deriveCycleDates` there (`formatCycleDate`, `formatCycleCloseUtc`)
7
+ * print words and stay in the applications; everything here is unix seconds in
8
+ * and unix seconds out.
9
+ *
10
+ * The clearing window is the fixed 48 hours immediately preceding an epoch
11
+ * end. Inside it, pending requests are being processed and cannot be modified,
12
+ * and the countdown runs to the epoch end. Outside it, the countdown runs to
13
+ * the next clearing-window start.
14
+ *
15
+ * The epoch end comes straight from the chain (`nextEpochStartTimestamp`, i.e.
16
+ * the SDK's `getNextEpochDate`) — the same value the protocol's own
17
+ * `getNextClearingPeriodDate` derives from — so the window always lines up
18
+ * with the real weekly schedule (Tue 06:00 → Thu 06:00 UTC on Base) instead of
19
+ * a projected subgraph timestamp that can drift off the grid.
20
+ */
21
+
22
+ /**
23
+ * Clearing-window length — a fixed 48h protocol constant. Exported so every
24
+ * consumer derives the window from the same number this module runs on.
25
+ */
26
+ export const CLEARING_WINDOW_SECONDS = 48 * 60 * 60;
27
+
28
+ /**
29
+ * Weekly cadence — the epoch schedule is fixed weekly (Tue → Thu UTC on Base).
30
+ * Used only to roll a cycle forward when a request lands inside a window that
31
+ * has already closed.
32
+ */
33
+ const WEEK_SECONDS = 7 * 24 * 60 * 60;
34
+
35
+ export type SettlementWindowState =
36
+ | {
37
+ phase: 'awaiting';
38
+ secondsUntilClearing: number;
39
+ nextClearingStart: number;
40
+ }
41
+ | { phase: 'clearing'; secondsUntilEpochEnd: number; epochEnd: number }
42
+ | { phase: 'unknown' };
43
+
44
+ export interface SettlementWindowInput {
45
+ /** Now, in unix seconds. */
46
+ nowSeconds: number;
47
+ /**
48
+ * Authoritative epoch end, in unix seconds — the chain's
49
+ * `nextEpochStartTimestamp` (SDK `getNextEpochDate`). During an epoch this
50
+ * is in the future; it refetches to the next boundary once it rolls.
51
+ */
52
+ nextEpochStart: number;
53
+ /** Clearing-window length; defaults to the 48h protocol constant. */
54
+ clearingWindowSeconds?: number;
55
+ }
56
+
57
+ /**
58
+ * Which phase of the weekly cycle `nowSeconds` falls in, and how long is left
59
+ * of it.
60
+ *
61
+ * `'unknown'` when no epoch boundary has been loaded yet, or when the one on
62
+ * hand is stale (it has already elapsed — the on-chain value refetches to the
63
+ * next boundary shortly after rollover). A caller must render its "no cycle
64
+ * loaded" state there, never a zeroed countdown.
65
+ */
66
+ export function computeSettlementWindow({
67
+ nowSeconds,
68
+ nextEpochStart,
69
+ clearingWindowSeconds = CLEARING_WINDOW_SECONDS,
70
+ }: SettlementWindowInput): SettlementWindowState {
71
+ // No epoch boundary loaded yet, or a stale one that already elapsed.
72
+ if (nextEpochStart <= 0 || nextEpochStart <= nowSeconds) {
73
+ return { phase: 'unknown' };
74
+ }
75
+
76
+ const clearingStart = nextEpochStart - clearingWindowSeconds;
77
+
78
+ if (nowSeconds < clearingStart) {
79
+ return {
80
+ phase: 'awaiting',
81
+ secondsUntilClearing: clearingStart - nowSeconds,
82
+ nextClearingStart: clearingStart,
83
+ };
84
+ }
85
+
86
+ // nowSeconds is in [clearingStart, nextEpochStart) — inside the window.
87
+ return {
88
+ phase: 'clearing',
89
+ secondsUntilEpochEnd: nextEpochStart - nowSeconds,
90
+ epochEnd: nextEpochStart,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * The next cycle boundary strictly after `nowSeconds`, in unix seconds — the
96
+ * cycle close (`nextEpochStart − 48h`) while the window is still open, the
97
+ * epoch end once we are inside it.
98
+ *
99
+ * `undefined` when there is no boundary left to wait for: no epoch boundary
100
+ * loaded, or a cached one that has already elapsed — the same staleness rule
101
+ * `computeSettlementWindow` applies before it reports `'unknown'`.
102
+ *
103
+ * Split out of the state machine because some consumers need the INSTANT
104
+ * rather than the phase: one to flush the cycle-dependent caches when the
105
+ * clock crosses it, one to move a pre-commit screen's snapshot clock at the
106
+ * same moment.
107
+ */
108
+ export function nextCycleBoundary(
109
+ nextEpochStart: number | undefined,
110
+ nowSeconds: number,
111
+ clearingWindowSeconds: number = CLEARING_WINDOW_SECONDS,
112
+ ): number | undefined {
113
+ if (!nextEpochStart || nextEpochStart <= 0) return undefined;
114
+ const clearingStart = nextEpochStart - clearingWindowSeconds;
115
+ if (nowSeconds < clearingStart) return clearingStart;
116
+ if (nowSeconds < nextEpochStart) return nextEpochStart;
117
+ return undefined;
118
+ }
119
+
120
+ export interface CycleDates {
121
+ /** Unix seconds — the next cycle close (clearing-window start). */
122
+ close: number;
123
+ /** Unix seconds — the outcome-confirmed-by boundary (close + 48h). */
124
+ outcome: number;
125
+ }
126
+
127
+ /**
128
+ * The cycle-close and outcome dates for a request submitted `now`.
129
+ *
130
+ * - The cycle "closes" (stops accepting requests, starts processing) at the
131
+ * start of the 48h clearing window, i.e. 48h before the epoch end.
132
+ * - Processing takes up to 48h, so the outcome is confirmed by the epoch end
133
+ * (close + 48h).
134
+ *
135
+ * Returns `null` when the epoch boundary is not available or is stale — a
136
+ * caller then omits the dates entirely (omit, don't stub).
137
+ *
138
+ * The common case is a request submitted OUTSIDE the clearing window: the
139
+ * close is `nextEpochStart − 48h` and the outcome is `nextEpochStart`. When
140
+ * the request lands INSIDE the current clearing window (that close is already
141
+ * in the past), it queues for the NEXT weekly cycle, so the close is advanced
142
+ * by whole weeks until it is in the future.
143
+ */
144
+ export function deriveCycleDates(
145
+ nextEpochStart: number | undefined,
146
+ nowSeconds: number,
147
+ ): CycleDates | null {
148
+ // No boundary loaded, or a stale one that already elapsed — the same
149
+ // staleness rule as `computeSettlementWindow`.
150
+ if (!nextEpochStart || nextEpochStart <= nowSeconds) return null;
151
+
152
+ let close = nextEpochStart - CLEARING_WINDOW_SECONDS;
153
+ let outcome = nextEpochStart;
154
+ // Inside the current clearing window the close already passed; a request
155
+ // now is queued for the next weekly cycle.
156
+ while (close <= nowSeconds) {
157
+ close += WEEK_SECONDS;
158
+ outcome += WEEK_SECONDS;
159
+ }
160
+ return { close, outcome };
161
+ }
@@ -0,0 +1,100 @@
1
+ import { isUnpredictableGas, isUserRejected } from './wallet-errors';
2
+
3
+ /**
4
+ * `isUserRejected` cases ported from kasu-ui
5
+ * `src/lib/web3/is-user-rejected.test.ts`. `isUnpredictableGas` had no test in
6
+ * kasu-mobile; one is written here.
7
+ */
8
+
9
+ describe('isUserRejected', () => {
10
+ it('detects EIP-1193 numeric code 4001', () => {
11
+ expect(isUserRejected({ code: 4001, message: 'whatever' })).toBe(true);
12
+ });
13
+
14
+ it('detects ACTION_REJECTED string code (ethers v5)', () => {
15
+ expect(
16
+ isUserRejected({ code: 'ACTION_REJECTED', message: 'rejected' }),
17
+ ).toBe(true);
18
+ });
19
+
20
+ it('detects "User rejected" message string', () => {
21
+ expect(isUserRejected(new Error('User rejected the request'))).toBe(
22
+ true,
23
+ );
24
+ });
25
+
26
+ it('detects "User denied" message string', () => {
27
+ expect(
28
+ isUserRejected(new Error('User denied transaction signature')),
29
+ ).toBe(true);
30
+ });
31
+
32
+ it('detects the ethers ACTION_REJECTED marker in a message', () => {
33
+ expect(
34
+ isUserRejected(new Error('transaction failed: ACTION_REJECTED')),
35
+ ).toBe(true);
36
+ });
37
+
38
+ it('is case-insensitive', () => {
39
+ expect(isUserRejected(new Error('user REJECTED the request'))).toBe(
40
+ true,
41
+ );
42
+ });
43
+
44
+ it('returns false for unrelated errors', () => {
45
+ expect(isUserRejected(new Error('Network error'))).toBe(false);
46
+ expect(isUserRejected(new Error('Insufficient funds'))).toBe(false);
47
+ });
48
+
49
+ it('returns false for null / undefined', () => {
50
+ expect(isUserRejected(null)).toBe(false);
51
+ expect(isUserRejected(undefined)).toBe(false);
52
+ });
53
+
54
+ it('handles non-Error throwables', () => {
55
+ expect(isUserRejected('user rejected')).toBe(true);
56
+ expect(isUserRejected('something else')).toBe(false);
57
+ });
58
+
59
+ it('is not fooled by a numeric code that only looks like 4001', () => {
60
+ expect(isUserRejected({ code: '4001' })).toBe(false);
61
+ expect(isUserRejected({ code: 4002 })).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe('isUnpredictableGas', () => {
66
+ it('detects the ethers v5 gas-estimation revert code', () => {
67
+ expect(isUnpredictableGas({ code: 'UNPREDICTABLE_GAS_LIMIT' })).toBe(
68
+ true,
69
+ );
70
+ });
71
+
72
+ it('does not fire on a user rejection', () => {
73
+ expect(isUnpredictableGas({ code: 'ACTION_REJECTED' })).toBe(false);
74
+ expect(isUnpredictableGas({ code: 4001 })).toBe(false);
75
+ });
76
+
77
+ it('does not fire on the code appearing only in a message', () => {
78
+ // The code is the signal; a message mentioning it is not one, or a
79
+ // logged error string would be mistaken for a revert.
80
+ expect(
81
+ isUnpredictableGas(new Error('UNPREDICTABLE_GAS_LIMIT')),
82
+ ).toBe(false);
83
+ expect(isUnpredictableGas('UNPREDICTABLE_GAS_LIMIT')).toBe(false);
84
+ });
85
+
86
+ it('returns false for null / undefined and for unrelated errors', () => {
87
+ expect(isUnpredictableGas(null)).toBe(false);
88
+ expect(isUnpredictableGas(undefined)).toBe(false);
89
+ expect(isUnpredictableGas(new Error('Network error'))).toBe(false);
90
+ });
91
+
92
+ it('reads the code off an ethers error object', () => {
93
+ const err = Object.assign(new Error('cannot estimate gas'), {
94
+ code: 'UNPREDICTABLE_GAS_LIMIT',
95
+ reason: 'execution reverted: ERC20: insufficient allowance',
96
+ });
97
+ expect(isUnpredictableGas(err)).toBe(true);
98
+ expect(isUserRejected(err)).toBe(false);
99
+ });
100
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Wallet and RPC error predicates — pure functions over `unknown`.
3
+ *
4
+ * Every consumer has to tell three things apart when a write fails: the lender
5
+ * changed their mind, the call would revert, and everything else. The first
6
+ * two must never be reported as a failure the lender should retry or contact
7
+ * support about, and each wallet spells them differently, so the shapes are
8
+ * enumerated once here.
9
+ *
10
+ * Lifted from kasu-ui's `src/lib/web3/is-user-rejected.ts` and kasu-mobile's
11
+ * `src/features/lending/lib/errors.ts`.
12
+ */
13
+
14
+ /**
15
+ * Did the lender reject the request in their wallet?
16
+ *
17
+ * Providers surface a rejection in different shapes:
18
+ * - MetaMask and most EIP-1193 wallets: `code: 4001`
19
+ * - WalletConnect and some Privy paths: `Error('User rejected the request')`
20
+ * - ethers v5 wraps it as `ACTION_REJECTED`
21
+ * - Coinbase Wallet sometimes: `code: 'ACTION_REJECTED'` as a string
22
+ *
23
+ * This is kasu-ui's implementation verbatim. kasu-mobile's copy additionally
24
+ * reads a nested `error.code` / `error.message`, an ethers `reason`, and the
25
+ * words "request rejected" / "declined"; a consumer that needs those shapes
26
+ * should keep its own check on top rather than assume they are covered here.
27
+ */
28
+ export function isUserRejected(err: unknown): boolean {
29
+ if (!err) return false;
30
+ if (typeof err === 'object') {
31
+ const code = (err as { code?: unknown }).code;
32
+ if (code === 4001 || code === 'ACTION_REJECTED') return true;
33
+ }
34
+ const msg = err instanceof Error ? err.message : String(err);
35
+ const lower = msg.toLowerCase();
36
+ return (
37
+ lower.includes('user rejected') ||
38
+ lower.includes('user denied') ||
39
+ lower.includes('rejected the request') ||
40
+ lower.includes('action_rejected')
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Did the wallet or RPC signal that the on-chain call would revert?
46
+ *
47
+ * ethers v5 raises `UNPREDICTABLE_GAS_LIMIT` when gas estimation reverts —
48
+ * most often an underlying `transferFrom` failing on an insufficient balance
49
+ * or allowance. Distinct from a rejection: nothing was refused by the lender,
50
+ * the transaction simply cannot succeed as composed, so the caller should
51
+ * re-check its preconditions rather than invite a retry.
52
+ */
53
+ export function isUnpredictableGas(err: unknown): boolean {
54
+ if (!err || typeof err !== 'object') return false;
55
+ return (err as { code?: unknown }).code === 'UNPREDICTABLE_GAS_LIMIT';
56
+ }
@@ -151,8 +151,14 @@ export const CHAIN_CONFIGS: Record<'base' | 'xdc' | 'xdc-usdc' | 'plume', ChainC
151
151
  ClearingCoordinator: '',
152
152
  ExternalTVL: '',
153
153
  },
154
+ // The frozen Plume history is indexed on the LEGACY Goldsky project,
155
+ // not the one the live chains use: the same path under the current
156
+ // project 404s. Verified 2026-09-05 — this URL answers
157
+ // `{ lendingPools { id name } }` with the three Plume pools; the
158
+ // current-project spelling returns HTTP 404. Note the `/gn` suffix,
159
+ // which the current project's URLs do not carry.
154
160
  subgraphUrl:
155
- 'https://api.goldsky.com/api/public/project_cmgzlpxm300765np2a19421om/subgraphs/kasu-plume/prod',
161
+ 'https://api.goldsky.com/api/public/project_cm9t3064xeuyn01tgctdo3c17/subgraphs/kasu-plume/prod/gn',
156
162
  directusUrl: 'https://kasu-finance.directus.app/',
157
163
  unusedPoolIds: [],
158
164
  poolMetadataMapping: undefined,
@@ -3,6 +3,13 @@
3
3
  * here from `src/tests/` so it runs beside the code it covers, and so the
4
4
  * opt-in `LIVE_TESTS` gate on `src/tests/` does not take it out of CI.
5
5
  */
6
+ import { DataService } from '../services/DataService/data-service';
7
+ import { NO_DIRECTUS_URL_MESSAGE } from '../services/DataService/directus-client';
8
+ import { Portfolio } from '../services/Portfolio/portfolio';
9
+ import { UserRequestStatus } from '../services/UserLending/subgraph-types';
10
+ import { UserRequest } from '../services/UserLending/types';
11
+ import { UserLending } from '../services/UserLending/user-lending';
12
+
6
13
  import { CHAIN_CONFIGS } from './chain-configs';
7
14
  import { DepositsFacade } from './deposits';
8
15
  import { Kasu } from './kasu';
@@ -52,6 +59,18 @@ describe('CHAIN_CONFIGS', () => {
52
59
  }
53
60
  });
54
61
 
62
+ it('plume points at the LEGACY goldsky project its frozen history is on', () => {
63
+ // Plume was indexed before the live chains moved to the current
64
+ // project; the same path under that project returns HTTP 404, so the
65
+ // retired deployment's history is only readable at this spelling —
66
+ // note the `/gn` suffix the current project's URLs do not carry.
67
+ const projectOf = (url: string): string => url.split('/')[5] ?? '';
68
+ expect(projectOf(CHAIN_CONFIGS.plume.subgraphUrl)).not.toBe(
69
+ projectOf(CHAIN_CONFIGS.base.subgraphUrl),
70
+ );
71
+ expect(CHAIN_CONFIGS.plume.subgraphUrl.endsWith('/gn')).toBe(true);
72
+ });
73
+
55
74
  it('xdc should have poolMetadataMapping', () => {
56
75
  const mapping = CHAIN_CONFIGS.xdc.poolMetadataMapping;
57
76
  expect(mapping).toBeDefined();
@@ -131,6 +150,35 @@ describe('Kasu.create()', () => {
131
150
  expect(kasu).toBeInstanceOf(Kasu);
132
151
  });
133
152
 
153
+ it('constructs without a directusUrl — on-chain data does not need one', () => {
154
+ // `createDirectus('')` throws `Invalid URL`, so an SDK configured
155
+ // without the (documented-optional) CMS URL used to be
156
+ // unconstructable rather than merely CMS-less.
157
+ const kasu = Kasu.create({
158
+ chain: 'base',
159
+ signerOrProvider: mockProvider as never,
160
+ configOverrides: { directusUrl: '' },
161
+ });
162
+
163
+ expect(kasu).toBeInstanceOf(Kasu);
164
+ expect(kasu.services.DataService).toBeDefined();
165
+ expect(kasu.services.UserLending).toBeDefined();
166
+ });
167
+
168
+ it('refuses a CMS-only call clearly when no directusUrl is configured', async () => {
169
+ const kasu = Kasu.create({
170
+ chain: 'base',
171
+ signerOrProvider: mockProvider as never,
172
+ configOverrides: { directusUrl: '' },
173
+ });
174
+
175
+ // A sentence naming what to configure, not a null dereference thrown
176
+ // from inside the vendor SDK.
177
+ await expect(
178
+ kasu.services.DataService.getPlatformOverview(),
179
+ ).rejects.toThrow(NO_DIRECTUS_URL_MESSAGE);
180
+ });
181
+
134
182
  it('should expose the underlying KasuSdk via .services', () => {
135
183
  const kasu = Kasu.create({
136
184
  chain: 'base',
@@ -201,6 +249,59 @@ describe('StrategiesFacade.calculateDepositLimits()', () => {
201
249
  });
202
250
  });
203
251
 
252
+ // ---------------------------------------------------------------------------
253
+ // PortfolioFacade.getRequestStates()
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe('PortfolioFacade.getRequestStates()', () => {
257
+ it('maps the transaction history through deriveRequestState', async () => {
258
+ const requests: UserRequest[] = [
259
+ makeUserRequest({ id: 'req-1' }),
260
+ makeUserRequest({
261
+ id: 'req-2',
262
+ requestType: 'Withdrawal',
263
+ status: UserRequestStatus.PROCESSED,
264
+ canCancel: false,
265
+ acceptedAmount: '100',
266
+ }),
267
+ ];
268
+ const userLending = {
269
+ getCurrentEpoch: (): Promise<string> => Promise.resolve('42'),
270
+ getUserRequests: (): Promise<UserRequest[]> =>
271
+ Promise.resolve(requests),
272
+ } as unknown as UserLending;
273
+
274
+ const facade = new PortfolioFacade(
275
+ {} as unknown as DataService,
276
+ userLending,
277
+ {} as unknown as Portfolio,
278
+ );
279
+
280
+ const states = await facade.getRequestStates('0xuser');
281
+
282
+ expect(states.map((s) => s.id)).toEqual(['req-1', 'req-2']);
283
+ expect(states[0].statusCode).toBe('pending');
284
+ expect(states[0].amount).toBe(100);
285
+ expect(states[1].statusCode).toBe('complete');
286
+ expect(states[1].amount).toBe(-100);
287
+ });
288
+
289
+ it('returns an empty list for a wallet with no history', async () => {
290
+ const userLending = {
291
+ getCurrentEpoch: (): Promise<string> => Promise.resolve('42'),
292
+ getUserRequests: (): Promise<UserRequest[]> => Promise.resolve([]),
293
+ } as unknown as UserLending;
294
+
295
+ const facade = new PortfolioFacade(
296
+ {} as unknown as DataService,
297
+ userLending,
298
+ {} as unknown as Portfolio,
299
+ );
300
+
301
+ await expect(facade.getRequestStates('0xuser')).resolves.toEqual([]);
302
+ });
303
+ });
304
+
204
305
  // ---------------------------------------------------------------------------
205
306
  // Type exports — verify key facade types are importable
206
307
  // ---------------------------------------------------------------------------
@@ -248,3 +349,26 @@ function makeTranche(raw: {
248
349
  },
249
350
  } as never;
250
351
  }
352
+
353
+
354
+ function makeUserRequest(overrides: Partial<UserRequest> = {}): UserRequest {
355
+ return {
356
+ id: 'req-1',
357
+ userId: '0xuser',
358
+ lendingPool: { id: '0xpool', name: 'Pool', tranches: [{ orderId: '0' }] },
359
+ requestType: 'Deposit',
360
+ trancheId: '0xtranche',
361
+ trancheName: 'Senior',
362
+ requestedAmount: '100',
363
+ acceptedAmount: '0',
364
+ rejectedAmount: '0',
365
+ timestamp: 1_700_000_000,
366
+ status: UserRequestStatus.REQUESTED,
367
+ canCancel: true,
368
+ events: [],
369
+ nftId: '',
370
+ apy: '0',
371
+ fixedTermConfig: undefined,
372
+ ...overrides,
373
+ };
374
+ }
@@ -1,5 +1,6 @@
1
1
  import { Provider } from '@ethersproject/providers';
2
2
 
3
+ import { deriveRequestState, RequestState } from '../domain/requests';
3
4
  import { DataService } from '../services/DataService/data-service';
4
5
  import { Portfolio } from '../services/Portfolio/portfolio';
5
6
  import { UserRequest } from '../services/UserLending/types';
@@ -70,4 +71,27 @@ export class PortfolioFacade {
70
71
  currentEpoch,
71
72
  );
72
73
  }
74
+
75
+ /**
76
+ * The same history, already derived into `RequestState` rows — status
77
+ * code, kind, signed amount, the cancelled-amount recovery, the bundled
78
+ * submission count and the cycle-open signal.
79
+ *
80
+ * ```ts
81
+ * const rows = await kasu.portfolio.getRequestStates('0xUser...');
82
+ * rows.filter((r) => r.statusCode === 'pending');
83
+ * ```
84
+ *
85
+ * Every application derives this from `getTransactionHistory` anyway, and
86
+ * the derivation is the part they were each getting subtly differently.
87
+ * Words are still the caller's: render `statusCode` in your own
88
+ * vocabulary, and call `getTrancheDisplayName` on `trancheName` at the
89
+ * view boundary.
90
+ */
91
+ async getRequestStates(
92
+ userAddress: `0x${string}`,
93
+ ): Promise<RequestState[]> {
94
+ const requests = await this.getTransactionHistory(userAddress);
95
+ return requests.map(deriveRequestState);
96
+ }
73
97
  }
package/src/index.ts CHANGED
@@ -55,6 +55,8 @@ export type {
55
55
  PlatformOverviewDirectus,
56
56
  } from './services/DataService/directus-types';
57
57
 
58
+ export { NO_DIRECTUS_URL_MESSAGE } from './services/DataService/directus-client';
59
+
58
60
  // ---------------------------------------------------------------------------
59
61
  // Re-export facade (high-level integrator API)
60
62
  // ---------------------------------------------------------------------------
@@ -1,13 +1,4 @@
1
- import {
2
- authentication,
3
- AuthenticationClient,
4
- createDirectus,
5
- DirectusClient,
6
- readItems,
7
- readSingleton,
8
- rest,
9
- RestClient,
10
- } from '@directus/sdk';
1
+ import { readItems, readSingleton } from '@directus/sdk';
11
2
  import { Provider } from '@ethersproject/abstract-provider';
12
3
  import { Signer } from 'ethers';
13
4
  import { formatUnits } from 'ethers/lib/utils';
@@ -23,9 +14,12 @@ import { getSystemVariablesQuery } from '../Locking/queries';
23
14
  import { SystemVariables } from '../Locking/types';
24
15
  import { filterArray } from '../shared';
25
16
 
17
+ import {
18
+ createDirectusClient,
19
+ KasuDirectusClient,
20
+ } from './directus-client';
26
21
  import {
27
22
  BadAndDoubtfulDebtsItems,
28
- DirectusSchema,
29
23
  FinancialReportingDocumentsItemsDirectus,
30
24
  KeyCreditMetricsDirectus,
31
25
  NftBoostDirectus,
@@ -67,9 +61,7 @@ import {
67
61
  export class DataService {
68
62
  private readonly _graph: GraphQLClient;
69
63
  private readonly _externalTvlAbi: KasuPoolExternalTVLAbi;
70
- private readonly _directus: DirectusClient<DirectusSchema> &
71
- AuthenticationClient<DirectusSchema> &
72
- RestClient<DirectusSchema>;
64
+ private readonly _directus: KasuDirectusClient;
73
65
  private _directusPoolOverview: PoolOverviewDirectus[] | undefined;
74
66
 
75
67
  constructor(
@@ -82,16 +74,7 @@ export class DataService {
82
74
  );
83
75
  this._graph = new GraphQLClient(_kasuConfig.subgraphUrl);
84
76
 
85
- if (_kasuConfig.directusUrl) {
86
- this._directus = createDirectus<DirectusSchema>(
87
- _kasuConfig.directusUrl,
88
- )
89
- .with(authentication())
90
- .with(rest());
91
- } else {
92
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
93
- this._directus = null as any;
94
- }
77
+ this._directus = createDirectusClient(_kasuConfig.directusUrl);
95
78
  }
96
79
 
97
80
  private getUrlFromFile(fileName: string): string {
@@ -0,0 +1,54 @@
1
+ import {
2
+ authentication,
3
+ AuthenticationClient,
4
+ createDirectus,
5
+ DirectusClient,
6
+ rest,
7
+ RestClient,
8
+ } from '@directus/sdk';
9
+
10
+ import { DirectusSchema } from './directus-types';
11
+
12
+ /** The Directus client shape both services hold. */
13
+ export type KasuDirectusClient = DirectusClient<DirectusSchema> &
14
+ AuthenticationClient<DirectusSchema> &
15
+ RestClient<DirectusSchema>;
16
+
17
+ /**
18
+ * The error a CMS-only call raises on a deployment configured without
19
+ * Directus. Named so a caller can match on it rather than on the message.
20
+ */
21
+ export const NO_DIRECTUS_URL_MESSAGE =
22
+ 'Kasu: this call needs Directus, but the SDK was configured without a ' +
23
+ '`directusUrl`. On-chain data (pools, tranches, positions, requests) ' +
24
+ 'works without one; CMS content does not.';
25
+
26
+ /**
27
+ * Build the Directus client, or a stand-in that refuses clearly.
28
+ *
29
+ * `directusUrl` is documented optional, and most of the SDK genuinely does not
30
+ * need it — pools, tranches, positions and request history all come from the
31
+ * subgraph and the chain. But `createDirectus('')` throws `Invalid URL` inside
32
+ * the constructor, so omitting the URL used to make the whole SDK
33
+ * unconstructable rather than merely CMS-less.
34
+ *
35
+ * With no URL, the services skip Directus where they can degrade (pool
36
+ * descriptions, images and Directus pool names simply do not appear, and the
37
+ * raw subgraph names are used instead), and a call that exists ONLY to read
38
+ * CMS content rejects with `NO_DIRECTUS_URL_MESSAGE` — a sentence that says
39
+ * what to configure, rather than a `null` dereference thrown from inside a
40
+ * vendor SDK.
41
+ */
42
+ export function createDirectusClient(directusUrl: string): KasuDirectusClient {
43
+ if (directusUrl) {
44
+ return createDirectus<DirectusSchema>(directusUrl)
45
+ .with(authentication())
46
+ .with(rest());
47
+ }
48
+ const refuse = (): never => {
49
+ throw new Error(NO_DIRECTUS_URL_MESSAGE);
50
+ };
51
+ // A stand-in, not a client: every entry point the services use goes
52
+ // through `request`, so refusing there covers all of them.
53
+ return { request: refuse } as unknown as KasuDirectusClient;
54
+ }