@kasufinance/kasu-sdk 2.6.0 → 2.7.1

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 (126) hide show
  1. package/README.md +167 -4
  2. package/dist/bundle.cjs.js +1389 -257
  3. package/dist/bundle.esm.js +1375 -258
  4. package/dist/domain/index.d.ts +5 -2
  5. package/dist/domain/index.js +3 -2
  6. package/dist/domain/index.js.map +1 -1
  7. package/dist/domain/requests.d.ts +42 -4
  8. package/dist/domain/requests.js +20 -4
  9. package/dist/domain/requests.js.map +1 -1
  10. package/dist/domain/revert-errors.d.ts +59 -0
  11. package/dist/domain/revert-errors.js +190 -0
  12. package/dist/domain/revert-errors.js.map +1 -0
  13. package/dist/domain/wallet-errors.d.ts +63 -8
  14. package/dist/domain/wallet-errors.js +115 -16
  15. package/dist/domain/wallet-errors.js.map +1 -1
  16. package/dist/facade/deposits.js +2 -1
  17. package/dist/facade/deposits.js.map +1 -1
  18. package/dist/facade/flows.d.ts +95 -0
  19. package/dist/facade/flows.js +116 -0
  20. package/dist/facade/flows.js.map +1 -0
  21. package/dist/facade/index.d.ts +3 -0
  22. package/dist/facade/index.js +3 -0
  23. package/dist/facade/index.js.map +1 -1
  24. package/dist/facade/kasu.d.ts +6 -2
  25. package/dist/facade/kasu.js +28 -3
  26. package/dist/facade/kasu.js.map +1 -1
  27. package/dist/facade/read-only.d.ts +12 -0
  28. package/dist/facade/read-only.js +13 -0
  29. package/dist/facade/read-only.js.map +1 -0
  30. package/dist/flows/deposit-flow.d.ts +332 -0
  31. package/dist/flows/deposit-flow.js +436 -0
  32. package/dist/flows/deposit-flow.js.map +1 -0
  33. package/dist/flows/flow.d.ts +72 -0
  34. package/dist/flows/flow.js +110 -0
  35. package/dist/flows/flow.js.map +1 -0
  36. package/dist/flows/index.d.ts +19 -0
  37. package/dist/flows/index.js +17 -0
  38. package/dist/flows/index.js.map +1 -0
  39. package/dist/flows/observable.d.ts +59 -0
  40. package/dist/flows/observable.js +81 -0
  41. package/dist/flows/observable.js.map +1 -0
  42. package/dist/flows/withdraw-flow.d.ts +99 -0
  43. package/dist/flows/withdraw-flow.js +83 -0
  44. package/dist/flows/withdraw-flow.js.map +1 -0
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.js +6 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/services/DataService/data-service.js +0 -1
  49. package/dist/services/DataService/data-service.js.map +1 -1
  50. package/package.json +8 -2
  51. package/src/domain/index.ts +10 -1
  52. package/src/domain/requests.ts +57 -4
  53. package/src/domain/revert-errors.ts +248 -0
  54. package/src/domain/wallet-errors.ts +135 -18
  55. package/src/facade/deposits.ts +2 -3
  56. package/src/facade/flows.ts +172 -0
  57. package/src/facade/index.ts +8 -0
  58. package/src/facade/kasu.ts +43 -3
  59. package/src/facade/read-only.ts +13 -0
  60. package/src/flows/deposit-flow.ts +884 -0
  61. package/src/flows/flow.ts +108 -0
  62. package/src/flows/index.ts +47 -0
  63. package/src/flows/observable.ts +97 -0
  64. package/src/flows/withdraw-flow.ts +210 -0
  65. package/src/index.ts +7 -0
  66. package/src/services/DataService/data-service.ts +0 -1
  67. package/dist/domain/au-minimum.test.d.ts +0 -1
  68. package/dist/domain/au-minimum.test.js +0 -202
  69. package/dist/domain/au-minimum.test.js.map +0 -1
  70. package/dist/domain/deposit-bounds.test.d.ts +0 -1
  71. package/dist/domain/deposit-bounds.test.js +0 -135
  72. package/dist/domain/deposit-bounds.test.js.map +0 -1
  73. package/dist/domain/loan-contract.test.d.ts +0 -1
  74. package/dist/domain/loan-contract.test.js +0 -255
  75. package/dist/domain/loan-contract.test.js.map +0 -1
  76. package/dist/domain/partners.test.d.ts +0 -1
  77. package/dist/domain/partners.test.js +0 -53
  78. package/dist/domain/partners.test.js.map +0 -1
  79. package/dist/domain/pools.test.d.ts +0 -1
  80. package/dist/domain/pools.test.js +0 -184
  81. package/dist/domain/pools.test.js.map +0 -1
  82. package/dist/domain/rates.test.d.ts +0 -1
  83. package/dist/domain/rates.test.js +0 -181
  84. package/dist/domain/rates.test.js.map +0 -1
  85. package/dist/domain/requests.test.d.ts +0 -1
  86. package/dist/domain/requests.test.js +0 -470
  87. package/dist/domain/requests.test.js.map +0 -1
  88. package/dist/domain/settlement.test.d.ts +0 -1
  89. package/dist/domain/settlement.test.js +0 -152
  90. package/dist/domain/settlement.test.js.map +0 -1
  91. package/dist/domain/tranche-display-name.test.d.ts +0 -1
  92. package/dist/domain/tranche-display-name.test.js +0 -58
  93. package/dist/domain/tranche-display-name.test.js.map +0 -1
  94. package/dist/domain/tranches.test.d.ts +0 -1
  95. package/dist/domain/tranches.test.js +0 -206
  96. package/dist/domain/tranches.test.js.map +0 -1
  97. package/dist/domain/wallet-errors.test.d.ts +0 -1
  98. package/dist/domain/wallet-errors.test.js +0 -71
  99. package/dist/domain/wallet-errors.test.js.map +0 -1
  100. package/dist/facade/config.test.d.ts +0 -1
  101. package/dist/facade/config.test.js +0 -216
  102. package/dist/facade/config.test.js.map +0 -1
  103. package/dist/facade/facade.test.d.ts +0 -1
  104. package/dist/facade/facade.test.js +0 -278
  105. package/dist/facade/facade.test.js.map +0 -1
  106. package/dist/services/Locking/calculate-apy.test.d.ts +0 -1
  107. package/dist/services/Locking/calculate-apy.test.js +0 -41
  108. package/dist/services/Locking/calculate-apy.test.js.map +0 -1
  109. package/dist/tests/sample.test.d.ts +0 -1
  110. package/dist/tests/sample.test.js +0 -59
  111. package/dist/tests/sample.test.js.map +0 -1
  112. package/src/domain/au-minimum.test.ts +0 -371
  113. package/src/domain/deposit-bounds.test.ts +0 -200
  114. package/src/domain/loan-contract.test.ts +0 -343
  115. package/src/domain/partners.test.ts +0 -83
  116. package/src/domain/pools.test.ts +0 -260
  117. package/src/domain/rates.test.ts +0 -254
  118. package/src/domain/requests.test.ts +0 -653
  119. package/src/domain/settlement.test.ts +0 -198
  120. package/src/domain/tranche-display-name.test.ts +0 -96
  121. package/src/domain/tranches.test.ts +0 -300
  122. package/src/domain/wallet-errors.test.ts +0 -100
  123. package/src/facade/config.test.ts +0 -265
  124. package/src/facade/facade.test.ts +0 -374
  125. package/src/services/Locking/calculate-apy.test.ts +0 -52
  126. package/src/tests/sample.test.ts +0 -63
@@ -0,0 +1,884 @@
1
+ import { BigNumber, utils } from 'ethers';
2
+
3
+ import {
4
+ asContractType,
5
+ buildLegacyContractRequestMessage,
6
+ buildLoanAgreementSignMessage,
7
+ encodeDepositData,
8
+ GenerateContractResponse,
9
+ } from '../domain/loan-contract';
10
+ import { decodeRevert } from '../domain/revert-errors';
11
+ import {
12
+ classifyWalletFailure,
13
+ isUnpredictableGas,
14
+ } from '../domain/wallet-errors';
15
+ import { DepositParams, KycParams } from '../facade/types';
16
+
17
+ import { Flow } from './flow';
18
+ import { WaitableTransaction } from './observable';
19
+
20
+ /**
21
+ * The KYC-gated deposit pipeline, headless.
22
+ *
23
+ * This is the state machine kasu-ui and kasu-mobile each hand-wrote and then
24
+ * had to keep in step by hand: sign an auth message, generate the loan
25
+ * agreement, park while the lender reads it, sign it, approve the EXACT amount,
26
+ * fetch the KYC signature, submit, wait. Two implementations of one money path
27
+ * is one too many, so it lives here once and each application drives it from
28
+ * its own UI.
29
+ *
30
+ * **No React, no copy, no I/O of its own.** Every side effect is an injected
31
+ * port and every observable state is a code — `{ step: 'approve', reason:
32
+ * 'cancelled' }`, never "USDC approval was cancelled in your wallet". The
33
+ * applications keep their own `DEPOSIT_STEP_ERRORS` tables and map the codes to
34
+ * their words, in their design system and their language. Nothing in this file
35
+ * may be shown to a lender.
36
+ *
37
+ * ## Order, and why each step is where it is
38
+ *
39
+ * 1. **Allowance pre-check** — before anything is signed, because it decides
40
+ * `approvalRequired`, which decides `stepTotal`. A badge that said "3 of 4"
41
+ * and then silently became "3 of 3" would be describing a pipeline the
42
+ * lender is not in. A failed read assumes an approve IS needed: the safe
43
+ * default is a redundant approval, never a reverted deposit.
44
+ * 2. **`generating-sign`** — the lender signs the auth message
45
+ * (`buildLoanAgreementSignMessage`, or the legacy builder). kasu-backend
46
+ * reconstructs that string byte-for-byte to verify the signature.
47
+ * 3. **`generating-fetch`** — the agreements service returns the contract.
48
+ * 4. **`awaiting-accept`** — the run PARKS on a promise the consumer settles
49
+ * with `acceptContract()` or `declineContract()`. This is the only point at
50
+ * which a lender is committing to anything.
51
+ * 5. **`accepting-sign`** — the acceptance signature, which becomes the
52
+ * on-chain `depositData` via `encodeDepositData`.
53
+ * 6. **The 5-minute TTL guard** — checked AFTER the accept, because that is
54
+ * where the idling happens. An expired agreement is refused here rather than
55
+ * broadcast as a transaction that cannot succeed.
56
+ * 7. **`approve`** — the EXACT amount, never `MaxUint256`. House rule, and it
57
+ * is why the allowance drops to zero after every deposit and why step 1
58
+ * reads it fresh rather than trusting a cache.
59
+ * 8. **`request-sign` / `request-confirm`** — KYC signature, then the deposit,
60
+ * then the receipt.
61
+ *
62
+ * ## Failure codes
63
+ *
64
+ * A wallet rejection (`isUserRejected`) is `'cancelled'` — the lender changed
65
+ * their mind, and telling them something broke would be a lie. Only a WALLET
66
+ * call is ever classified that way: the HTTP ports are always `'failed'`,
67
+ * because a backend that words a refusal "declined" did not involve the
68
+ * lender's wallet. A revert on the request step is read for WHAT reverted: a
69
+ * protocol error the ABI declares is `'reverted'` with the name, and only a
70
+ * token balance or allowance failure — or a revert nothing can decode — stays
71
+ * `'insufficient-balance'`. Everything else is `'failed'` and carries the
72
+ * original error for the consumer's crash reporter.
73
+ *
74
+ * ## Both signatures are checked before they are used
75
+ *
76
+ * A wallet is not obliged to hand back 65 bytes, and `encodeDepositData` will
77
+ * not catch one that does not: `defaultAbiCoder` encodes any even-length hex
78
+ * as `bytes`, `0x` included, so a malformed acceptance signature becomes a
79
+ * `depositData` blob that is broadcast, mined, and then can never be verified
80
+ * by the agreements service. Both personal signs are therefore length-checked
81
+ * where they are taken, and a bad one fails the step it was taken on rather
82
+ * than the one it would eventually have broken.
83
+ *
84
+ * ```ts
85
+ * const flow = new DepositFlow(ports);
86
+ * const stop = flow.subscribe((s) => render(s));
87
+ * await flow.start({ poolId, trancheId, amount, userAddress, ... });
88
+ * // …the consumer shows `flow.state.contract` and calls:
89
+ * await flow.acceptContract();
90
+ * ```
91
+ */
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Codes
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Where the run is. `success`, `declined` and `error` are terminal; everything
99
+ * else is in flight.
100
+ */
101
+ export type DepositPhase =
102
+ | 'idle'
103
+ | 'generating-sign'
104
+ | 'generating-fetch'
105
+ | 'awaiting-accept'
106
+ | 'accepting-sign'
107
+ | 'approve'
108
+ | 'request-sign'
109
+ | 'request-confirm'
110
+ | 'success'
111
+ | 'declined'
112
+ | 'error';
113
+
114
+ /**
115
+ * The four steps a lender sees as a badge. `approve` drops out of the sequence
116
+ * when the allowance already covers the deposit, which is why `stepIndex` and
117
+ * `stepTotal` are published rather than derived by each consumer.
118
+ */
119
+ export type DepositStep = 'generate' | 'confirm' | 'approve' | 'request';
120
+
121
+ /**
122
+ * Why a run did not reach `success`, as a code plus the step it happened on.
123
+ *
124
+ * The consumer maps this to its own words. `error` carries the underlying
125
+ * throw for a crash reporter — a `cancelled` and a `contract-expired` do not,
126
+ * because neither is a fault worth reporting.
127
+ */
128
+ export type DepositFailure =
129
+ | { step: DepositStep; reason: 'cancelled' }
130
+ | { step: DepositStep; reason: 'failed'; error: unknown }
131
+ | { step: 'request'; reason: 'insufficient-balance'; error: unknown }
132
+ | {
133
+ step: 'request';
134
+ reason: 'reverted';
135
+ /**
136
+ * The custom error the contract reverted with, exactly as the ABI
137
+ * declares it — `'ClearingIsPending'`, `'LendingPoolIsStopped'`,
138
+ * `'UserNotKycd'`, and the rest of
139
+ * `ILendingPoolManagerAbi` / `IKasuAllowListAbi`.
140
+ *
141
+ * Render `reverted` with generic copy and special-case only the
142
+ * names you have words for: a contract upgrade can add an error, and
143
+ * a consumer that assumed the set was closed would have nothing to
144
+ * show for the new one.
145
+ */
146
+ revertError: string;
147
+ error: unknown;
148
+ }
149
+ | { step: 'request'; reason: 'contract-expired' };
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Ports
153
+ // ---------------------------------------------------------------------------
154
+
155
+ export type { WaitableTransaction };
156
+
157
+ /** What the KYC signing service hands back. */
158
+ export interface KycSignature {
159
+ signature: string;
160
+ blockExpiration: number | string;
161
+ }
162
+
163
+ /**
164
+ * The `/contract/generate` body, assembled by the flow and posted by the
165
+ * consumer's own port — through its server-side proxy (kasu-ui) or straight to
166
+ * the agreements service (kasu-mobile). The SDK never makes the call itself and
167
+ * never learns the URL or the key.
168
+ */
169
+ export interface GenerateContractRequest {
170
+ /**
171
+ * The lender's address, LOWERCASED. The legacy message embeds this casing
172
+ * and kasu-backend rebuilds the string from the body, so the two must
173
+ * agree.
174
+ */
175
+ address: string;
176
+ /** Signature over `signedMessage`. */
177
+ signature: string;
178
+ /** ms-epoch. The same value `signedMessage` states — do not re-clock it. */
179
+ timestamp: number;
180
+ /**
181
+ * The exact text that was signed. Sent so a consumer can log or assert on
182
+ * it; the backend rebuilds it from the other fields rather than trusting
183
+ * this one.
184
+ */
185
+ signedMessage: string;
186
+ poolId: string;
187
+ trancheId: string;
188
+ /** `'0'` for a variable-rate deposit. */
189
+ fixedTermConfigId: string;
190
+ /**
191
+ * The deposit in DISPLAY units, forwarded verbatim from
192
+ * `DepositFlowInput.depositAmount`. kasu-backend cross-checks it against
193
+ * the leading number of `amountLabel`.
194
+ */
195
+ depositAmount?: number;
196
+ /** The four human-readable fields, present only on the new format. */
197
+ strategyName?: string;
198
+ region?: string;
199
+ optionName?: string;
200
+ amountLabel?: string;
201
+ }
202
+
203
+ /**
204
+ * The human-readable `/contract/generate` format (BD deck slide 19): the lender
205
+ * signs a statement naming the strategy, region, option and amount.
206
+ *
207
+ * `amountLabel` must be derived from the same value as `depositAmount` — the
208
+ * backend refuses a message that states an amount other than the one being
209
+ * executed. The SDK does not format it, because formatting is the
210
+ * application's (and its locale's) business.
211
+ */
212
+ export interface LoanAgreementRequest {
213
+ format: 'loan-agreement';
214
+ strategyName: string;
215
+ region: string;
216
+ optionName: string;
217
+ amountLabel: string;
218
+ }
219
+
220
+ /**
221
+ * The legacy `I request contract content for {address} at {timestamp}.`
222
+ * format, which kasu-backend still accepts and `/contract/resolve` has no
223
+ * alternative to.
224
+ */
225
+ export interface LegacyContractRequest {
226
+ format: 'legacy';
227
+ }
228
+
229
+ export type ContractMessageRequest =
230
+ | LoanAgreementRequest
231
+ | LegacyContractRequest;
232
+
233
+ /**
234
+ * Every side effect the pipeline needs, injected.
235
+ *
236
+ * `kasu.flows.deposit()` fills `readAllowance`, `approve`, `deposit` and
237
+ * `buildKycParams` from the SDK's own signer-bound implementations; the three
238
+ * that reach the consumer's own backend or wallet have no sensible default and
239
+ * are always supplied by the application.
240
+ */
241
+ export interface DepositPorts {
242
+ /** EIP-191 personal sign. Rejects when the lender refuses. */
243
+ signMessage(message: string): Promise<string>;
244
+ /** POST the generate request; resolve with the agreements service's reply. */
245
+ generateContract(
246
+ req: GenerateContractRequest,
247
+ ): Promise<GenerateContractResponse>;
248
+ /** Build the Nexera KYC params. Defaults to `kasu.deposits.buildKycParams`. */
249
+ buildKycParams(
250
+ userAddress: `0x${string}`,
251
+ ): KycParams | Promise<KycParams>;
252
+ /** Exchange those params for a signature at the consumer's own backend. */
253
+ getKycSignature(params: KycParams): Promise<KycSignature>;
254
+ /** ERC-20 `allowance(owner, spender)`, in base units. */
255
+ readAllowance(owner: string, spender: string): Promise<BigNumber>;
256
+ /** ERC-20 `approve(spender, amount)`. The flow only ever passes the EXACT amount. */
257
+ approve(spender: string, amount: BigNumber): Promise<WaitableTransaction>;
258
+ /** `requestDepositWithKyc`. Defaults to `kasu.deposits.deposit`. */
259
+ deposit(params: DepositParams): Promise<WaitableTransaction>;
260
+ /** ms-epoch clock. Defaults to `Date.now`; injected so the TTL is testable. */
261
+ now?(): number;
262
+ }
263
+
264
+ /** Construction options. `kasu.flows.deposit()` fills `spender` in. */
265
+ export interface DepositFlowOptions {
266
+ /** Agreement validity window; defaults to `CONTRACT_TTL_MS`. */
267
+ contractTtlMs?: number;
268
+ /**
269
+ * The ERC-20 spender every run approves and deposits through, when the
270
+ * input does not name one. `kasu.flows.deposit()` passes this chain's
271
+ * `contracts.LendingPoolManager`, which is the only contract the default
272
+ * deposit port calls.
273
+ */
274
+ spender?: string;
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Input and state
279
+ // ---------------------------------------------------------------------------
280
+
281
+ export interface DepositFlowInput {
282
+ poolId: string;
283
+ trancheId: string;
284
+ /** The deposit in BASE units (6dp for USDC and AUDD). */
285
+ amount: BigNumber;
286
+ /** `'0'` for a variable-rate deposit. */
287
+ fixedTermConfigId: string;
288
+ userAddress: `0x${string}`;
289
+ /**
290
+ * The ERC-20 spender, when it is NOT this chain's `LendingPoolManager`.
291
+ *
292
+ * Leave it out: `kasu.flows.deposit()` defaults it from the chain config,
293
+ * and the default deposit port calls no other contract. It exists for a
294
+ * consumer that replaced the `deposit` port with one that spends
295
+ * somewhere else — a wrong spender is an approval granted to the wrong
296
+ * contract and then a revert diagnosed as `insufficient-balance`.
297
+ */
298
+ spender?: string;
299
+ /** Which signed-message format to use, and its fields. */
300
+ contractMessage: ContractMessageRequest;
301
+ /**
302
+ * The deposit in DISPLAY units, for the generate request only.
303
+ *
304
+ * NOT derived from `amount`: turning base units back into a display number
305
+ * is formatting, and formatting is the application's job — it is also the
306
+ * application that produced `amountLabel`, and kasu-backend refuses the two
307
+ * if they disagree. Pass the same value both were built from.
308
+ */
309
+ depositAmount?: number;
310
+ }
311
+
312
+ export interface DepositState {
313
+ phase: DepositPhase;
314
+ /** The step `phase` belongs to; `null` only while idle. */
315
+ step: DepositStep | null;
316
+ /** 1-based badge position of `step`; `0` while idle. */
317
+ stepIndex: number;
318
+ /** `4`, or `3` when the allowance already covers the deposit. */
319
+ stepTotal: number;
320
+ /** Whether the approve step is in scope for this run. */
321
+ approvalRequired: boolean;
322
+ /** The generated agreement, from `generating-fetch` onwards. */
323
+ contract: GenerateContractResponse | null;
324
+ /** Set with `phase: 'error'`, cleared by `reset()`. */
325
+ failure: DepositFailure | null;
326
+ }
327
+
328
+ /** Generated agreements are valid for five minutes upstream. */
329
+ export const CONTRACT_TTL_MS = 5 * 60 * 1000;
330
+
331
+ /** What a run without a spender, from either source, fails with. */
332
+ export const NO_SPENDER_MESSAGE =
333
+ 'DepositFlow: no ERC-20 spender; build the flow with kasu.flows.deposit() or pass `spender` on the input';
334
+
335
+ /** What a malformed auth signature fails the generate step with. */
336
+ export const INVALID_AUTH_SIGNATURE_MESSAGE =
337
+ 'DepositFlow: the wallet returned a malformed authentication signature; expected 65 bytes of 0x-prefixed hex';
338
+
339
+ /** What a malformed acceptance signature fails the confirm step with. */
340
+ export const INVALID_ACCEPTANCE_SIGNATURE_MESSAGE =
341
+ 'DepositFlow: the wallet returned a malformed acceptance signature; expected 65 bytes of 0x-prefixed hex';
342
+
343
+ /**
344
+ * Is this an EIP-191 personal sign, in the only shape the protocol accepts?
345
+ *
346
+ * 65 bytes — `r`, `s`, `v` — 0x-prefixed. Nothing downstream checks it: the
347
+ * ABI coder takes any even-length hex as `bytes`, so `'0x'` and a 64-byte
348
+ * string both encode happily into a `depositData` the agreements service then
349
+ * cannot verify against anything. The check has to happen where the signature
350
+ * is taken, which is here.
351
+ */
352
+ function isPersonalSignature(signature: unknown): signature is string {
353
+ return utils.isHexString(signature, 65);
354
+ }
355
+
356
+ const INITIAL: DepositState = {
357
+ phase: 'idle',
358
+ step: null,
359
+ stepIndex: 0,
360
+ stepTotal: 4,
361
+ approvalRequired: true,
362
+ contract: null,
363
+ failure: null,
364
+ };
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Internals
368
+ // ---------------------------------------------------------------------------
369
+
370
+ /**
371
+ * How the park ends, as data.
372
+ *
373
+ * It used to be signalled by throwing tagged `Error`s and sniffing them again
374
+ * on the way out, which meant a port error whose message happened to read
375
+ * `contract-declined` ENDED THE RUN AS A DECLINE — a lender's deposit
376
+ * abandoned on a string coincidence. A discriminated result cannot be
377
+ * counterfeited by an error message: `failed` carries the throw, and only
378
+ * `declineContract()` can produce `declined`.
379
+ */
380
+ type AcceptOutcome =
381
+ | { kind: 'accepted'; signature: string }
382
+ | { kind: 'declined' }
383
+ | { kind: 'failed'; error: unknown }
384
+ /** `reset()` unparked it. The run's token check drops everything after. */
385
+ | { kind: 'abandoned' };
386
+
387
+ /** 1-based badge position, with `approve` dropped when it is out of scope. */
388
+ function stepIndexOf(step: DepositStep, approvalRequired: boolean): number {
389
+ const order: DepositStep[] = approvalRequired
390
+ ? ['generate', 'confirm', 'approve', 'request']
391
+ : ['generate', 'confirm', 'request'];
392
+ return order.indexOf(step) + 1;
393
+ }
394
+
395
+ function badgeFor(
396
+ step: DepositStep,
397
+ approvalRequired: boolean,
398
+ ): Pick<DepositState, 'step' | 'stepIndex' | 'stepTotal'> {
399
+ return {
400
+ step,
401
+ stepIndex: stepIndexOf(step, approvalRequired),
402
+ stepTotal: approvalRequired ? 4 : 3,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * The request step's extra outcomes, in the order the evidence supports.
408
+ *
409
+ * A revert is read for WHAT reverted before it is read as a shortfall.
410
+ * `requestDepositWithKyc` reverts for a whole family of declared reasons —
411
+ * `LendingPoolIsStopped`, `ClearingIsPending`, `UserNotKycd`, `UserBlocked`,
412
+ * `UserNotInAllowList`, `InvalidTranche`, `BlockExpired` — and every one of
413
+ * them used to arrive as `insufficient-balance`, which told a fully funded
414
+ * lender to top up a wallet that was never short while their pool was simply
415
+ * mid-clearing. So: a decoded protocol error is `reverted` and names itself.
416
+ *
417
+ * Only a token balance or allowance failure — or revert data nothing can
418
+ * decode, on the `UNPREDICTABLE_GAS_LIMIT` ethers raises for a failed gas
419
+ * estimate — is still `insufficient-balance`, which is the case that reason
420
+ * was named for and the one where topping up is genuinely the answer.
421
+ */
422
+ function classifyRequest(err: unknown): DepositFailure {
423
+ const revert = decodeRevert(err);
424
+ if (revert?.family === 'protocol') {
425
+ return {
426
+ step: 'request',
427
+ reason: 'reverted',
428
+ revertError: revert.name,
429
+ error: err,
430
+ };
431
+ }
432
+ if (revert !== null || isUnpredictableGas(err)) {
433
+ return { step: 'request', reason: 'insufficient-balance', error: err };
434
+ }
435
+ return classifyWalletFailure('request', err);
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // The flow
440
+ // ---------------------------------------------------------------------------
441
+
442
+ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
443
+ private readonly _ttlMs: number;
444
+ private readonly _defaultSpender: string | undefined;
445
+ private readonly _now: () => number;
446
+
447
+ /**
448
+ * The accept handshake. The run parks on this promise; `acceptContract`,
449
+ * `declineContract` and `reset` each settle it with an `AcceptOutcome`.
450
+ * Cleared the moment it settles so a stale resolver from an abandoned run
451
+ * can never leak into the next one.
452
+ */
453
+ private _accept: { settle: (outcome: AcceptOutcome) => void } | null = null;
454
+
455
+ /**
456
+ * The run token that is between `acceptContract()` and the wallet
457
+ * settling, or `null`.
458
+ *
459
+ * A token rather than a boolean, because the flag has to belong to the RUN
460
+ * that set it: after `reset()` out of a wallet prompt that never answers,
461
+ * the abandoned run's `finally` may not arrive for minutes, and a boolean
462
+ * left standing refuses both Accept and Decline on every run after it.
463
+ * A stale token simply is not the current generation.
464
+ */
465
+ private _acceptingFor: number | null = null;
466
+
467
+ constructor(
468
+ private readonly _ports: DepositPorts,
469
+ opts?: DepositFlowOptions,
470
+ ) {
471
+ super(INITIAL);
472
+ this._ttlMs = opts?.contractTtlMs ?? CONTRACT_TTL_MS;
473
+ this._defaultSpender = opts?.spender;
474
+ // Called through the ports object, never captured off it: a consumer
475
+ // whose clock is a method on its own object keeps its `this`.
476
+ this._now = (): number => _ports.now?.() ?? Date.now();
477
+ }
478
+
479
+ /**
480
+ * Sign the agreement and resume the parked run. A no-op when nothing is
481
+ * parked, so a double tap cannot sign twice.
482
+ */
483
+ async acceptContract(): Promise<void> {
484
+ const bridge = this._accept;
485
+ const contract = this._store.state.contract;
486
+ if (!bridge || !contract || this._isAccepting()) return;
487
+ const token = this._store.generation;
488
+ this._acceptingFor = token;
489
+ this._store.patch(
490
+ {
491
+ phase: 'accepting-sign',
492
+ ...badgeFor('confirm', this._store.state.approvalRequired),
493
+ },
494
+ token,
495
+ );
496
+ try {
497
+ const signature = await this._ports.signMessage(
498
+ contract.contractMessage,
499
+ );
500
+ // Checked HERE, not at the encoder: this is the signature the
501
+ // agreements service verifies the deposit against, and a wallet
502
+ // that returned something other than 65 bytes has not signed
503
+ // anything. Failing the confirm step names the step that produced
504
+ // it; letting it through would put an unverifiable blob on chain.
505
+ if (!isPersonalSignature(signature)) {
506
+ bridge.settle({
507
+ kind: 'failed',
508
+ error: new Error(INVALID_ACCEPTANCE_SIGNATURE_MESSAGE),
509
+ });
510
+ return;
511
+ }
512
+ bridge.settle({ kind: 'accepted', signature });
513
+ } catch (err) {
514
+ // A WALLET error, and the only one this flow classifies as a
515
+ // possible cancellation on the confirm step.
516
+ bridge.settle({ kind: 'failed', error: err });
517
+ } finally {
518
+ // Only if this run still holds it: a `reset()` during the prompt
519
+ // may have started another one, and that one's flag is its own.
520
+ if (this._acceptingFor === token) this._acceptingFor = null;
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Back out of the agreement. The run ends on `declined` — a legitimate
526
+ * choice, not a failure, and `state.failure` stays null.
527
+ *
528
+ * Ignored once `acceptContract()` has opened the wallet: an agreement in
529
+ * the middle of being signed cannot also be refused. `reset()` is the way
530
+ * out of a prompt that never answers.
531
+ */
532
+ declineContract(): void {
533
+ const bridge = this._accept;
534
+ if (!bridge || this._isAccepting()) return;
535
+ this._accept = null;
536
+ bridge.settle({ kind: 'declined' });
537
+ }
538
+
539
+ /** True only while THIS generation is waiting on the acceptance signature. */
540
+ private _isAccepting(): boolean {
541
+ return (
542
+ this._acceptingFor !== null &&
543
+ this._store.isCurrent(this._acceptingFor)
544
+ );
545
+ }
546
+
547
+ /** `reset()`: unpark the abandoned run and drop its handshake. */
548
+ protected override _onAbandon(): void {
549
+ const bridge = this._accept;
550
+ this._accept = null;
551
+ this._acceptingFor = null;
552
+ bridge?.settle({ kind: 'abandoned' });
553
+ }
554
+
555
+ // -----------------------------------------------------------------------
556
+
557
+ protected async _run(
558
+ input: DepositFlowInput,
559
+ token: number,
560
+ ): Promise<void> {
561
+ this._accept = null;
562
+ this._acceptingFor = null;
563
+ const ports = this._ports;
564
+ const owner = input.userAddress.toLowerCase();
565
+
566
+ // 0. The spender, from the input or from the chain config the facade
567
+ // built this flow with. Without one there is nothing to read an
568
+ // allowance against and nothing to approve — and guessing would
569
+ // grant an approval to the wrong contract.
570
+ const spender = input.spender ?? this._defaultSpender;
571
+ if (!spender) {
572
+ this._fail(token, true, {
573
+ step: 'generate',
574
+ reason: 'failed',
575
+ error: new Error(NO_SPENDER_MESSAGE),
576
+ });
577
+ return;
578
+ }
579
+
580
+ // 1. Allowance pre-check. Decides `approvalRequired` — and therefore
581
+ // the badge total — before the lender is shown a single step. Read
582
+ // live, never cached: an exact-amount approval is fully consumed by
583
+ // the deposit it paid for, so a stale allowance is exactly the value
584
+ // that would wrongly skip the approve and revert the deposit.
585
+ let approvalRequired = true;
586
+ try {
587
+ const allowance = await ports.readAllowance(owner, spender);
588
+ approvalRequired = allowance.lt(input.amount);
589
+ } catch {
590
+ // A read failure is not a reason to skip an approval. Assume one is
591
+ // needed: the cost is a redundant approve, the alternative is a
592
+ // reverted deposit.
593
+ approvalRequired = true;
594
+ }
595
+ if (!this._store.isCurrent(token)) return;
596
+
597
+ // 2. Generate — sign the auth message.
598
+ this._store.patch(
599
+ {
600
+ approvalRequired,
601
+ phase: 'generating-sign',
602
+ ...badgeFor('generate', approvalRequired),
603
+ },
604
+ token,
605
+ );
606
+
607
+ const timestamp = this._now();
608
+ const signedMessage = buildAuthMessage(input, owner, timestamp);
609
+ let signature: string;
610
+ try {
611
+ signature = await ports.signMessage(signedMessage);
612
+ } catch (err) {
613
+ this._fail(
614
+ token,
615
+ approvalRequired,
616
+ classifyWalletFailure('generate', err),
617
+ );
618
+ return;
619
+ }
620
+ // The same EIP-191 shape as the acceptance, and the same reason to
621
+ // check it: kasu-backend verifies this one to decide whether to issue
622
+ // an agreement at all, so a malformed signature is a 4xx that says
623
+ // nothing about which step produced it. Named here instead.
624
+ if (!isPersonalSignature(signature)) {
625
+ this._fail(token, approvalRequired, {
626
+ step: 'generate',
627
+ reason: 'failed',
628
+ error: new Error(INVALID_AUTH_SIGNATURE_MESSAGE),
629
+ });
630
+ return;
631
+ }
632
+ if (!this._store.isCurrent(token)) return;
633
+
634
+ // 3. Generate — POST the request.
635
+ this._store.patch(
636
+ {
637
+ phase: 'generating-fetch',
638
+ ...badgeFor('generate', approvalRequired),
639
+ },
640
+ token,
641
+ );
642
+ let contract: GenerateContractResponse;
643
+ try {
644
+ contract = await ports.generateContract({
645
+ address: owner,
646
+ signature,
647
+ timestamp,
648
+ signedMessage,
649
+ poolId: input.poolId,
650
+ trancheId: input.trancheId,
651
+ fixedTermConfigId: input.fixedTermConfigId,
652
+ depositAmount: input.depositAmount,
653
+ ...displayFieldsOf(input.contractMessage),
654
+ });
655
+ } catch (err) {
656
+ // Never a wallet rejection: this step is an HTTP call, and the
657
+ // lender's wallet was not involved in it. A backend that happened
658
+ // to echo the words "user rejected" must not be reported to them as
659
+ // something they did.
660
+ this._fail(token, approvalRequired, {
661
+ step: 'generate',
662
+ reason: 'failed',
663
+ error: err,
664
+ });
665
+ return;
666
+ }
667
+ if (!this._store.isCurrent(token)) return;
668
+
669
+ // 4. Park on the agreement until the consumer accepts or declines.
670
+ const outcome = await new Promise<AcceptOutcome>((resolve) => {
671
+ this._accept = { settle: resolve };
672
+ this._store.patch(
673
+ {
674
+ phase: 'awaiting-accept',
675
+ contract,
676
+ ...badgeFor('confirm', approvalRequired),
677
+ },
678
+ token,
679
+ );
680
+ });
681
+ this._accept = null;
682
+ // An abandoned run lands here too — `reset()` unparks it. The token
683
+ // check is what tells the two apart.
684
+ if (!this._store.isCurrent(token)) return;
685
+ if (outcome.kind === 'declined') {
686
+ this._store.patch(
687
+ { phase: 'declined', ...badgeFor('confirm', approvalRequired) },
688
+ token,
689
+ );
690
+ return;
691
+ }
692
+ if (outcome.kind !== 'accepted') {
693
+ if (outcome.kind === 'failed') {
694
+ this._fail(
695
+ token,
696
+ approvalRequired,
697
+ classifyWalletFailure('confirm', outcome.error),
698
+ );
699
+ }
700
+ return;
701
+ }
702
+
703
+ // 5. TTL guard. Checked here because this is where the idling happens:
704
+ // the lender has just spent as long as they wanted reading. An
705
+ // expired agreement is refused rather than broadcast — the on-chain
706
+ // call would revert, after a wallet prompt and a gas estimate, with
707
+ // nothing on screen explaining why.
708
+ if (this._now() > contract.timestamp + this._ttlMs) {
709
+ this._fail(token, approvalRequired, {
710
+ step: 'request',
711
+ reason: 'contract-expired',
712
+ });
713
+ return;
714
+ }
715
+
716
+ // The acceptance signature is already known to be 65 bytes, but the
717
+ // encoder also reads `timestamp`, `contractVersion` and
718
+ // `contractType` off a response the SDK did not produce. `start()`
719
+ // promises it does not reject, so anything that throws in here becomes
720
+ // a failure the consumer can render rather than an unhandled rejection
721
+ // in an application that was told it never had to catch one.
722
+ let depositData: string;
723
+ try {
724
+ depositData = encodeDepositData({
725
+ signature: outcome.signature,
726
+ timestamp: contract.timestamp,
727
+ contractVersion: contract.contractVersion,
728
+ contractType: asContractType(contract.contractType),
729
+ });
730
+ } catch (err) {
731
+ this._fail(token, approvalRequired, {
732
+ step: 'request',
733
+ reason: 'failed',
734
+ error: err,
735
+ });
736
+ return;
737
+ }
738
+
739
+ // 6. Approve — the EXACT amount, never `MaxUint256`. House rule: an
740
+ // unlimited allowance outlives the deposit it was granted for, and a
741
+ // later exploit of the spender would drain a wallet that has long
742
+ // since stopped lending.
743
+ if (approvalRequired) {
744
+ this._store.patch(
745
+ { phase: 'approve', ...badgeFor('approve', approvalRequired) },
746
+ token,
747
+ );
748
+ try {
749
+ const tx = await ports.approve(spender, input.amount);
750
+ await tx.wait();
751
+ } catch (err) {
752
+ this._fail(
753
+ token,
754
+ approvalRequired,
755
+ classifyWalletFailure('approve', err),
756
+ );
757
+ return;
758
+ }
759
+ if (!this._store.isCurrent(token)) return;
760
+ }
761
+
762
+ // 7. Request — KYC signature, then the deposit and its receipt.
763
+ this._store.patch(
764
+ { phase: 'request-sign', ...badgeFor('request', approvalRequired) },
765
+ token,
766
+ );
767
+
768
+ // The two KYC ports reach the consumer's own backend, so they fail the
769
+ // way the generate step does: `failed`, with the error kept. Running
770
+ // them through the rejection classifier would let a backend wording —
771
+ // "Declined", "request rejected" — end a run as "you cancelled in your
772
+ // wallet", with the real error discarded and nothing to report.
773
+ let kyc: KycSignature;
774
+ try {
775
+ const kycParams = await ports.buildKycParams(
776
+ owner as `0x${string}`,
777
+ );
778
+ kyc = await ports.getKycSignature(kycParams);
779
+ } catch (err) {
780
+ this._fail(token, approvalRequired, {
781
+ step: 'request',
782
+ reason: 'failed',
783
+ error: err,
784
+ });
785
+ return;
786
+ }
787
+ if (!this._store.isCurrent(token)) return;
788
+
789
+ try {
790
+ const tx = await ports.deposit({
791
+ poolId: input.poolId,
792
+ trancheId: input.trancheId,
793
+ amount: input.amount,
794
+ kycSignature: {
795
+ blockExpiration: kyc.blockExpiration,
796
+ signature: kyc.signature,
797
+ },
798
+ depositData,
799
+ fixedTermConfigId: input.fixedTermConfigId,
800
+ });
801
+ this._store.patch(
802
+ {
803
+ phase: 'request-confirm',
804
+ ...badgeFor('request', approvalRequired),
805
+ },
806
+ token,
807
+ );
808
+ await tx.wait();
809
+ } catch (err) {
810
+ this._fail(token, approvalRequired, classifyRequest(err));
811
+ return;
812
+ }
813
+ if (!this._store.isCurrent(token)) return;
814
+
815
+ this._store.patch(
816
+ { phase: 'success', ...badgeFor('request', approvalRequired) },
817
+ token,
818
+ );
819
+ }
820
+
821
+ private _fail(
822
+ token: number,
823
+ approvalRequired: boolean,
824
+ failure: DepositFailure,
825
+ ): void {
826
+ this._store.patch(
827
+ {
828
+ phase: 'error',
829
+ failure,
830
+ ...badgeFor(failure.step, approvalRequired),
831
+ },
832
+ token,
833
+ );
834
+ }
835
+ }
836
+
837
+ // ---------------------------------------------------------------------------
838
+ // Message building
839
+ // ---------------------------------------------------------------------------
840
+
841
+ /**
842
+ * The auth message, in whichever format the consumer asked for. Both builders
843
+ * are the byte-exact protocol strings from `domain/loan-contract` — the string
844
+ * signed here and the body posted from `_run` state the SAME timestamp and the
845
+ * SAME lowercased address, because kasu-backend rebuilds one from the other.
846
+ */
847
+ function buildAuthMessage(
848
+ input: DepositFlowInput,
849
+ owner: string,
850
+ timestamp: number,
851
+ ): string {
852
+ if (input.contractMessage.format === 'legacy') {
853
+ return buildLegacyContractRequestMessage(owner, timestamp);
854
+ }
855
+ const { strategyName, region, optionName, amountLabel } =
856
+ input.contractMessage;
857
+ return buildLoanAgreementSignMessage({
858
+ strategyName,
859
+ region,
860
+ optionName,
861
+ amountLabel,
862
+ timestamp,
863
+ });
864
+ }
865
+
866
+ /**
867
+ * The four display fields, present only on the new format. The backend picks
868
+ * its verification path on their presence: all four → the human-readable
869
+ * format, any missing → the legacy string.
870
+ */
871
+ function displayFieldsOf(
872
+ message: ContractMessageRequest,
873
+ ): Pick<
874
+ GenerateContractRequest,
875
+ 'strategyName' | 'region' | 'optionName' | 'amountLabel'
876
+ > {
877
+ if (message.format === 'legacy') return {};
878
+ return {
879
+ strategyName: message.strategyName,
880
+ region: message.region,
881
+ optionName: message.optionName,
882
+ amountLabel: message.amountLabel,
883
+ };
884
+ }