@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,248 @@
1
+ import { ethers } from 'ethers';
2
+
3
+ import { IKasuAllowListAbi__factory } from '../contracts/factories/IKasuAllowListAbi__factory';
4
+ import { ILendingPoolManagerAbi__factory } from '../contracts/factories/ILendingPoolManagerAbi__factory';
5
+
6
+ /**
7
+ * Revert decoding — turn the bytes a failed call came back with into the name
8
+ * the contract actually reverted with.
9
+ *
10
+ * `isUnpredictableGas` says only THAT the call would revert. That was enough
11
+ * while the answer was always the same one: a `transferFrom` failing on a
12
+ * balance that cannot cover the deposit. It is not enough any more, because
13
+ * `requestDepositWithKyc` reverts for a whole family of reasons the ABI
14
+ * declares and the lender can do nothing about — the pool is stopped, the pool
15
+ * is mid-clearing, their KYC lapsed, they are not on the allow list, the
16
+ * allow-list signature's block window closed. Every one of those was reported
17
+ * as "insufficient balance", so a fully funded lender was told to top up a
18
+ * wallet that was never short.
19
+ *
20
+ * The revert data carries the answer. This module extracts it from whatever
21
+ * envelope the provider wrapped it in and matches the 4-byte selector against
22
+ * the custom errors the protocol declares.
23
+ *
24
+ * Pure, like everything else in `domain/`: bytes in, a name out. It never
25
+ * decides what a lender is shown — `DepositFlow` maps the name to a code and
26
+ * the application maps the code to its own words.
27
+ */
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // What a decoded revert is
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Which family a recognised revert belongs to, because the two are acted on
35
+ * differently.
36
+ *
37
+ * `erc20` is the case `insufficient-balance` was named for and still means:
38
+ * the token transfer itself failed, so the lender genuinely has to fund or
39
+ * re-approve. `protocol` is everything else the protocol declares — a
40
+ * condition of the pool or the lender's standing, which topping up cannot fix.
41
+ */
42
+ export type RevertFamily = 'protocol' | 'erc20';
43
+
44
+ export interface DecodedRevert {
45
+ /** The custom error's name, exactly as the ABI declares it. */
46
+ name: string;
47
+ family: RevertFamily;
48
+ /** The reason of a `require(..., "…")` revert, when it was one. */
49
+ reason?: string;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // The selector tables
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * OpenZeppelin v5's ERC-20 errors. They are not in either Kasu ABI because no
58
+ * Kasu contract declares them — they arrive from the TOKEN, through the
59
+ * manager's `transferFrom`, and they are the modern spelling of the string
60
+ * reverts below.
61
+ */
62
+ const ERC20_ERROR_ABI = [
63
+ 'error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)',
64
+ 'error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed)',
65
+ ];
66
+
67
+ /**
68
+ * The protocol errors that mean the ERC-20 transfer failed rather than the
69
+ * protocol refusing.
70
+ *
71
+ * `SafeERC20FailedOperation` is OpenZeppelin's wrapper around a token call
72
+ * that returned false or reverted, which on this path IS the `transferFrom`;
73
+ * `AddressInsufficientBalance` is its native-value sibling. Both belong with
74
+ * the balance case, not with the pool conditions — a lender who sees one of
75
+ * them really does need to fund or re-approve.
76
+ */
77
+ const ERC20_FAMILY_NAMES: ReadonlySet<string> = new Set([
78
+ 'AddressInsufficientBalance',
79
+ 'ERC20InsufficientAllowance',
80
+ 'ERC20InsufficientBalance',
81
+ 'SafeERC20FailedOperation',
82
+ ]);
83
+
84
+ /** Solidity's `Error(string)`, the selector every `require` reason arrives under. */
85
+ const STRING_REVERT_SELECTOR = '0x08c379a0';
86
+
87
+ /**
88
+ * The `require` reasons that are a balance or an allowance, across the token
89
+ * implementations in circulation: OpenZeppelin v4's sentences, and the
90
+ * shorthand the minimal implementations use.
91
+ */
92
+ const ERC20_REASON_PATTERNS: readonly RegExp[] = [
93
+ /exceeds\s+balance/,
94
+ /exceeds\s+allowance/,
95
+ /insufficient\s+balance/,
96
+ /insufficient\s+allowance/,
97
+ /transfer_from_failed/,
98
+ /transferfrom\s+failed/,
99
+ /low-level\s+call\s+failed/,
100
+ ];
101
+
102
+ /**
103
+ * selector → error name, built once on first use.
104
+ *
105
+ * Lazily, because a consumer that never sees a revert should not pay for three
106
+ * `Interface` constructions at import. The two Kasu tables are the GENERATED
107
+ * typechain ABIs, so this cannot drift from the contracts: regenerate the ABIs
108
+ * and the new errors are recognised with no edit here.
109
+ */
110
+ let selectorTable: Map<string, string> | null = null;
111
+
112
+ function selectors(): Map<string, string> {
113
+ if (selectorTable) return selectorTable;
114
+ const table = new Map<string, string>();
115
+ const interfaces = [
116
+ // The manager the deposit is called against, and the allow list it
117
+ // delegates the KYC and block-window checks to.
118
+ ILendingPoolManagerAbi__factory.createInterface(),
119
+ IKasuAllowListAbi__factory.createInterface(),
120
+ new ethers.utils.Interface(ERC20_ERROR_ABI),
121
+ ];
122
+ for (const iface of interfaces) {
123
+ for (const fragment of Object.values(iface.errors)) {
124
+ // First writer wins: the same name declared by both ABIs is the
125
+ // same selector, so the duplicate would be a no-op anyway.
126
+ const sighash = iface.getSighash(fragment);
127
+ if (!table.has(sighash)) table.set(sighash, fragment.name);
128
+ }
129
+ }
130
+ selectorTable = table;
131
+ return table;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Getting the bytes out of the envelope
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * How deep to look for the revert data. Providers nest it differently —
140
+ * ethers' own error wraps the provider's, which wraps the RPC body — and a
141
+ * bound is what keeps a self-referencing error object from being walked
142
+ * forever.
143
+ */
144
+ const MAX_DEPTH = 6;
145
+
146
+ /** The keys ethers and the providers under it put a wrapped error on. */
147
+ const NESTED_KEYS = [
148
+ 'data',
149
+ 'error',
150
+ 'originalError',
151
+ 'info',
152
+ 'cause',
153
+ 'body',
154
+ ] as const;
155
+
156
+ /** At least a 4-byte selector, and a whole number of bytes. */
157
+ function isRevertData(value: string): boolean {
158
+ return (
159
+ value.length >= 10 &&
160
+ value.length % 2 === 0 &&
161
+ /^0x[0-9a-fA-F]+$/.test(value)
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Find the revert data on an error, wherever the provider left it.
167
+ *
168
+ * ethers v5 raises `UNPREDICTABLE_GAS_LIMIT` with the provider's own error
169
+ * attached, and each provider nests the bytes one layer differently:
170
+ * `error.data`, `error.error.data`, `error.data.originalError.data`, or only
171
+ * inside the JSON `body` of a `SERVER_ERROR`. Rather than enumerate the
172
+ * products, walk a fixed set of keys to a fixed depth and take the first value
173
+ * that is shaped like revert data.
174
+ */
175
+ export function extractRevertData(err: unknown): string | null {
176
+ return walk(err, 0);
177
+ }
178
+
179
+ function walk(node: unknown, depth: number): string | null {
180
+ if (node == null || depth > MAX_DEPTH) return null;
181
+ if (typeof node === 'string') {
182
+ if (isRevertData(node)) return node;
183
+ // A `SERVER_ERROR`'s body: the RPC's JSON reply, verbatim.
184
+ if (!node.startsWith('{')) return null;
185
+ try {
186
+ return walk(JSON.parse(node), depth + 1);
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+ if (typeof node !== 'object') return null;
192
+ const record = node as Record<string, unknown>;
193
+ for (const key of NESTED_KEYS) {
194
+ const found = walk(record[key], depth + 1);
195
+ if (found) return found;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Decoding
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Decode a failed call's revert into the error the contract named, or `null`
206
+ * when there is nothing recognisable to name.
207
+ *
208
+ * `null` is the honest answer for an unknown selector, an unrelated `require`
209
+ * string, or an error with no revert data at all — the caller keeps whatever
210
+ * it did before rather than inventing a diagnosis from bytes it cannot read.
211
+ */
212
+ export function decodeRevert(err: unknown): DecodedRevert | null {
213
+ const data = extractRevertData(err);
214
+ if (!data) return null;
215
+ const selector = data.slice(0, 10).toLowerCase();
216
+
217
+ if (selector === STRING_REVERT_SELECTOR) {
218
+ const reason = decodeStringRevert(data);
219
+ if (reason === null) return null;
220
+ return isErc20Reason(reason)
221
+ ? { name: 'Error', family: 'erc20', reason }
222
+ : null;
223
+ }
224
+
225
+ const name = selectors().get(selector);
226
+ if (!name) return null;
227
+ return {
228
+ name,
229
+ family: ERC20_FAMILY_NAMES.has(name) ? 'erc20' : 'protocol',
230
+ };
231
+ }
232
+
233
+ function decodeStringRevert(data: string): string | null {
234
+ try {
235
+ const [reason] = ethers.utils.defaultAbiCoder.decode(
236
+ ['string'],
237
+ `0x${data.slice(10)}`,
238
+ ) as [string];
239
+ return reason;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ function isErc20Reason(reason: string): boolean {
246
+ const lower = reason.toLowerCase();
247
+ return ERC20_REASON_PATTERNS.some((pattern) => pattern.test(lower));
248
+ }
@@ -8,37 +8,125 @@
8
8
  * enumerated once here.
9
9
  *
10
10
  * Lifted from kasu-ui's `src/lib/web3/is-user-rejected.ts` and kasu-mobile's
11
- * `src/features/lending/lib/errors.ts`.
11
+ * `src/features/lending/lib/errors.ts` — and now the UNION of the two, so
12
+ * neither app has to keep a wrapper on top of this one.
12
13
  */
13
14
 
14
15
  /**
15
16
  * Did the lender reject the request in their wallet?
16
17
  *
17
- * Providers surface a rejection in different shapes:
18
+ * Providers surface a rejection in different shapes, and the union of them is
19
+ * the point of this function existing once:
18
20
  * - MetaMask and most EIP-1193 wallets: `code: 4001`
21
+ * - Coinbase Wallet and ethers v5: `code: 'ACTION_REJECTED'`
22
+ * - viem: a `UserRejectedRequestError` name
19
23
  * - 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
24
+ * - a provider error WRAPPED by another layer, carrying the real code and
25
+ * message on a nested `error` — the shape Privy's embedded wallet
26
+ * surfaces on Expo, where the outer object says nothing useful
27
+ * - ethers' own `reason` field, which is often the only place the text lands
28
+ * - a PLAIN OBJECT carrying `message`, which `String(err)` would read as
29
+ * `"[object Object]"`
22
30
  *
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.
31
+ * Previously kasu-ui's implementation verbatim, with kasu-mobile keeping its
32
+ * own superset on top. That is precisely the drift this layer exists to stop —
33
+ * a rejection kasu-mobile recognised and kasu-ui did not was reported to the
34
+ * same lender as a failure on one app and a cancellation on the other. The
35
+ * union lives here; the mobile wrapper goes.
36
+ *
37
+ * ## The text is read for a SUBJECT, not for a keyword
38
+ *
39
+ * The words alone are not the signal. "Declined" and "request rejected" are
40
+ * also what a rate limiter, a risk engine and a KYC decision say, and those
41
+ * arrive wrapped in exactly the same envelope a wallet error does — ethers'
42
+ * `SERVER_ERROR` around `-32603` carries the upstream body on a nested
43
+ * `error.message`, so `{ error: { message: 'request rejected: rate limit
44
+ * exceeded' } }` is an RPC refusing to serve us, not a lender refusing to
45
+ * sign. Reporting one as the other tells a lender they cancelled something
46
+ * they never saw, and DROPS the real error on the floor.
47
+ *
48
+ * So a rejection is a machine-readable wallet code, or a sentence that names
49
+ * the party who did it: "user rejected", "declined by the user", "cancelled by
50
+ * the wallet". A bare "declined" is not a rejection, whatever else is on the
51
+ * envelope.
52
+ *
53
+ * Within that rule, being generous is the safe direction: calling a genuine
54
+ * fault a cancellation costs a lender one retry, while calling a deliberate
55
+ * rejection a failure sends them to support to report a bug that does not
56
+ * exist.
27
57
  */
28
58
  export function isUserRejected(err: unknown): boolean {
29
59
  if (!err) return false;
30
60
  if (typeof err === 'object') {
31
- const code = (err as { code?: unknown }).code;
32
- if (code === 4001 || code === 'ACTION_REJECTED') return true;
61
+ const e = err as {
62
+ code?: unknown;
63
+ error?: { code?: unknown } | null;
64
+ };
65
+ if (isRejectionCode(e.code)) return true;
66
+ // A wrapped provider error: the outer layer's code is its own, the
67
+ // inner one is the wallet's.
68
+ if (isRejectionCode(e.error?.code)) return true;
33
69
  }
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
- );
70
+ const lower = rejectionText(err).toLowerCase();
71
+ return USER_REJECTION_PATTERNS.some((pattern) => pattern.test(lower));
72
+ }
73
+
74
+ function isRejectionCode(code: unknown): boolean {
75
+ return code === 4001 || code === 'ACTION_REJECTED';
76
+ }
77
+
78
+ /**
79
+ * The wordings that name the wallet or the person at it.
80
+ *
81
+ * Each one carries a SUBJECT — the identifier a wallet library uses, or the
82
+ * verb with the party who performed it. That is what separates a lender
83
+ * pressing Reject from a server declining to answer, and it is why none of
84
+ * these is a bare "declined" or "rejected".
85
+ */
86
+ const USER_REJECTION_PATTERNS: readonly RegExp[] = [
87
+ // Machine-readable markers that only a wallet layer emits, arriving as
88
+ // text because something in between stringified the error.
89
+ /action_rejected/,
90
+ /user_rejected/,
91
+ /userrejectedrequest/,
92
+ // "the user did it", in the orders the wallets write it.
93
+ /user\s+(?:has\s+)?(?:rejected|denied|declined|refused|cancell?ed)/,
94
+ /(?:rejected|denied|declined|refused|cancell?ed)\s+by\s+(?:the\s+)?(?:user|wallet|signer|owner)/,
95
+ // The wallet as the subject, which is how some embedded wallets word it.
96
+ /(?:wallet|signer)\s+(?:rejected|denied|declined|refused)/,
97
+ ];
98
+
99
+ /**
100
+ * Every place a wallet might have put the words: the message, ethers' `reason`,
101
+ * the error's `name` (viem puts the whole signal there), and a wrapped error's
102
+ * own three. Joined rather than picked, because which one carries the text
103
+ * depends on how many layers wrapped it.
104
+ *
105
+ * A separator is used rather than a bare space: two fields must not be able to
106
+ * form a phrase across the join that neither of them said.
107
+ */
108
+ function rejectionText(err: unknown): string {
109
+ if (typeof err !== 'object' || err === null) return String(err);
110
+ const e = err as {
111
+ message?: unknown;
112
+ reason?: unknown;
113
+ name?: unknown;
114
+ error?: {
115
+ message?: unknown;
116
+ reason?: unknown;
117
+ name?: unknown;
118
+ } | null;
119
+ };
120
+ return [
121
+ e.message,
122
+ e.reason,
123
+ e.name,
124
+ e.error?.message,
125
+ e.error?.reason,
126
+ e.error?.name,
127
+ ]
128
+ .filter((part): part is string => typeof part === 'string')
129
+ .join(' | ');
42
130
  }
43
131
 
44
132
  /**
@@ -54,3 +142,32 @@ export function isUnpredictableGas(err: unknown): boolean {
54
142
  if (!err || typeof err !== 'object') return false;
55
143
  return (err as { code?: unknown }).code === 'UNPREDICTABLE_GAS_LIMIT';
56
144
  }
145
+
146
+ /** A step that failed, as a code plus whether the lender chose it. */
147
+ export type WalletFailure<Step> =
148
+ | { step: Step; reason: 'cancelled' }
149
+ | { step: Step; reason: 'failed'; error: unknown };
150
+
151
+ /**
152
+ * The `cancelled` / `failed` split, for a WALLET call on any step of any flow.
153
+ *
154
+ * A lender who pressed Reject is not a fault. Reporting one as the other is
155
+ * how a support queue fills with people who did exactly what they meant to.
156
+ *
157
+ * Generic in the step so both flows share one implementation — it was
158
+ * duplicated byte-for-byte in each of them, which is the same drift this
159
+ * layer exists to stop, one level up.
160
+ *
161
+ * Only pass it an error a WALLET produced. An HTTP port's throw is always a
162
+ * `failed`: the lender's wallet was not involved in it, so a backend that
163
+ * happens to word a refusal "declined" must never be shown to them as
164
+ * something they did.
165
+ */
166
+ export function classifyWalletFailure<Step>(
167
+ step: Step,
168
+ err: unknown,
169
+ ): WalletFailure<Step> {
170
+ return isUserRejected(err)
171
+ ? { step, reason: 'cancelled' }
172
+ : { step, reason: 'failed', error: err };
173
+ }
@@ -2,6 +2,7 @@ import { ContractTransaction } from 'ethers';
2
2
 
3
3
  import { UserLending } from '../services/UserLending/user-lending';
4
4
 
5
+ import { READ_ONLY_MESSAGE } from './read-only';
5
6
  import { DepositParams, KycParams, WithdrawParams } from './types';
6
7
 
7
8
  /**
@@ -32,9 +33,7 @@ export class DepositsFacade {
32
33
  */
33
34
  private assertWritable(): void {
34
35
  if (this._isReadOnly) {
35
- throw new Error(
36
- 'Kasu: this instance is read-only; call kasu.connect(signer) first',
37
- );
36
+ throw new Error(READ_ONLY_MESSAGE);
38
37
  }
39
38
  }
40
39
 
@@ -0,0 +1,172 @@
1
+ import { BigNumber } from 'ethers';
2
+
3
+ import type { IERC20MetadataAbi } from '../contracts';
4
+ import {
5
+ DepositFlow,
6
+ DepositFlowOptions,
7
+ DepositPorts,
8
+ WaitableTransaction,
9
+ WithdrawFlow,
10
+ WithdrawPorts,
11
+ } from '../flows';
12
+
13
+ import { DepositsFacade } from './deposits';
14
+ import { READ_ONLY_MESSAGE } from './read-only';
15
+ import { KycParams } from './types';
16
+
17
+ /**
18
+ * The ports a consumer MUST supply for a deposit, plus optional overrides for
19
+ * the ones the SDK can serve itself.
20
+ *
21
+ * The three required ones all reach the application's own backend or wallet:
22
+ * the SDK has no opinion on how a lender signs, which proxy the agreements
23
+ * service sits behind, or where the KYC signature is fetched — and it must not
24
+ * learn any of them, because it is a public package.
25
+ */
26
+ export type DepositFlowPortOverrides = Pick<
27
+ DepositPorts,
28
+ 'signMessage' | 'generateContract' | 'getKycSignature'
29
+ > &
30
+ Partial<DepositPorts>;
31
+
32
+ /**
33
+ * Every withdraw port has an SDK default except `getKycSignature`, which has
34
+ * no default at all and which turns the KYC pre-check on by being supplied.
35
+ */
36
+ export type WithdrawFlowPortOverrides = Partial<WithdrawPorts>;
37
+
38
+ /**
39
+ * Builds `DepositFlow` / `WithdrawFlow` instances wired to THIS Kasu instance:
40
+ * its chain's stable token, its `LendingPoolManager`, its signer.
41
+ *
42
+ * ```ts
43
+ * const flow = kasu.connect(signer).flows.deposit({
44
+ * signMessage: (m) => signer.signMessage(m),
45
+ * generateContract: (req) => postToMyProxy(req),
46
+ * getKycSignature: (p) => postToMyBackend(p),
47
+ * });
48
+ * ```
49
+ *
50
+ * A flow built from a read-only instance constructs fine and reads fine — the
51
+ * write ports throw `READ_ONLY_MESSAGE` when the run reaches them, exactly as
52
+ * `kasu.deposits.deposit` does. Constructing is not the mistake; submitting is.
53
+ *
54
+ * It holds the `DepositsFacade` and NOTHING the facade already owns: no
55
+ * `UserLending`, no chain id, no signer. Two paths to one behaviour is how the
56
+ * KYC params a flow built came to differ from the ones `kasu.deposits`
57
+ * built — the same class of drift the flows themselves exist to end.
58
+ */
59
+ export class FlowsFacade {
60
+ constructor(
61
+ private readonly _deposits: DepositsFacade,
62
+ /**
63
+ * The chain's stable token, bound to whatever the Kasu instance holds.
64
+ * A factory rather than a contract: it is one `new Contract`, and a
65
+ * cached binding would outlive the config it was built from.
66
+ */
67
+ private readonly _erc20: () => IERC20MetadataAbi,
68
+ /** The read-only flag `Kasu` already computed — never re-derived here. */
69
+ private readonly _isReadOnly: boolean,
70
+ /**
71
+ * The ERC-20 spender every deposit run approves: this chain's
72
+ * `LendingPoolManager`, which is the only contract the default deposit
73
+ * port calls. Passed to the flow so a consumer never has to hand-wire
74
+ * an address whose only wrong value grants an approval to the wrong
75
+ * contract.
76
+ */
77
+ private readonly _spender: string,
78
+ ) {}
79
+
80
+ /**
81
+ * A deposit pipeline. `readAllowance`, `approve`, `deposit` and
82
+ * `buildKycParams` default to the SDK's own implementations; pass any of
83
+ * them to override (kasu-ui approves through its sponsored-gas path, for
84
+ * one).
85
+ *
86
+ * Each default is applied per key with `??`, not by spreading `ports` over
87
+ * them: `{ ...defaults, ...ports }` lets an EXPLICITLY undefined value
88
+ * delete the default it was meant to keep, and
89
+ * `approve: sponsoredGas ? sponsoredApprove : undefined` is exactly how a
90
+ * consumer writes a conditional override.
91
+ */
92
+ deposit(
93
+ ports: DepositFlowPortOverrides,
94
+ opts?: DepositFlowOptions,
95
+ ): DepositFlow {
96
+ return new DepositFlow(
97
+ {
98
+ signMessage: ports.signMessage,
99
+ generateContract: ports.generateContract,
100
+ getKycSignature: ports.getKycSignature,
101
+ buildKycParams:
102
+ ports.buildKycParams ??
103
+ ((userAddress: `0x${string}`): KycParams =>
104
+ this._deposits.buildKycParams(userAddress)),
105
+ readAllowance:
106
+ ports.readAllowance ??
107
+ ((owner: string, spender: string): Promise<BigNumber> =>
108
+ this._erc20().allowance(owner, spender)),
109
+ approve:
110
+ ports.approve ??
111
+ (async (
112
+ spender: string,
113
+ amount: BigNumber,
114
+ ): Promise<WaitableTransaction> => {
115
+ this._assertWritable();
116
+ // The EXACT amount the flow asked for. Nothing here
117
+ // rounds it up, and nothing here substitutes
118
+ // MaxUint256.
119
+ return await this._erc20().approve(spender, amount);
120
+ }),
121
+ deposit:
122
+ ports.deposit ??
123
+ ((params): Promise<WaitableTransaction> =>
124
+ this._deposits.deposit(params)),
125
+ now: ports.now,
126
+ },
127
+ {
128
+ contractTtlMs: opts?.contractTtlMs,
129
+ // Per key here too, for the same reason the ports are: an
130
+ // explicit `spender: undefined` must not delete the default.
131
+ spender: opts?.spender ?? this._spender,
132
+ },
133
+ );
134
+ }
135
+
136
+ /**
137
+ * A withdrawal pipeline. Both write ports and `buildKycParams` default to
138
+ * this instance; supplying `getKycSignature` turns the KYC pre-check on.
139
+ * Defaults are applied per key, for the reason `deposit()` gives.
140
+ */
141
+ withdraw(ports: WithdrawFlowPortOverrides = {}): WithdrawFlow {
142
+ return new WithdrawFlow({
143
+ buildKycParams:
144
+ ports.buildKycParams ??
145
+ ((userAddress: `0x${string}`): KycParams =>
146
+ this._deposits.buildKycParams(userAddress)),
147
+ getKycSignature: ports.getKycSignature,
148
+ withdraw:
149
+ ports.withdraw ??
150
+ ((params): Promise<WaitableTransaction> =>
151
+ this._deposits.withdraw({
152
+ poolId: params.poolId,
153
+ trancheId: params.trancheId,
154
+ amount: params.amount,
155
+ })),
156
+ withdrawMax:
157
+ ports.withdrawMax ??
158
+ ((
159
+ poolId: string,
160
+ trancheId: string,
161
+ userAddress: string,
162
+ ): Promise<WaitableTransaction> =>
163
+ this._deposits.withdrawMax(poolId, trancheId, userAddress)),
164
+ });
165
+ }
166
+
167
+ private _assertWritable(): void {
168
+ if (this._isReadOnly) {
169
+ throw new Error(READ_ONLY_MESSAGE);
170
+ }
171
+ }
172
+ }
@@ -5,6 +5,14 @@ export { Kasu } from './kasu';
5
5
  export { StrategiesFacade } from './strategies';
6
6
  export { DepositsFacade } from './deposits';
7
7
  export { PortfolioFacade } from './user-portfolio';
8
+ export { FlowsFacade } from './flows';
9
+ export type {
10
+ DepositFlowPortOverrides,
11
+ WithdrawFlowPortOverrides,
12
+ } from './flows';
13
+
14
+ // The one refusal every write path shares
15
+ export { READ_ONLY_MESSAGE } from './read-only';
8
16
 
9
17
  // Chain configurations
10
18
  export { CHAIN_CONFIGS } from './chain-configs';