@kasufinance/kasu-sdk 2.8.0 → 2.10.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 (88) hide show
  1. package/README.md +120 -9
  2. package/dist/bundle.cjs.js +2068 -478
  3. package/dist/bundle.esm.js +2030 -480
  4. package/dist/domain/batch-proof.d.ts +210 -0
  5. package/dist/domain/batch-proof.js +384 -0
  6. package/dist/domain/batch-proof.js.map +1 -0
  7. package/dist/domain/explorer-urls.d.ts +33 -0
  8. package/dist/domain/explorer-urls.js +38 -0
  9. package/dist/domain/explorer-urls.js.map +1 -0
  10. package/dist/domain/fixed-term.d.ts +74 -0
  11. package/dist/domain/fixed-term.js +86 -0
  12. package/dist/domain/fixed-term.js.map +1 -0
  13. package/dist/domain/index.d.ts +12 -2
  14. package/dist/domain/index.js +7 -2
  15. package/dist/domain/index.js.map +1 -1
  16. package/dist/domain/partners.d.ts +29 -0
  17. package/dist/domain/partners.js +20 -0
  18. package/dist/domain/partners.js.map +1 -1
  19. package/dist/domain/requests.d.ts +24 -0
  20. package/dist/domain/requests.js +33 -0
  21. package/dist/domain/requests.js.map +1 -1
  22. package/dist/domain/sponsored-calls.d.ts +74 -0
  23. package/dist/domain/sponsored-calls.js +156 -0
  24. package/dist/domain/sponsored-calls.js.map +1 -0
  25. package/dist/domain/yields.d.ts +121 -0
  26. package/dist/domain/yields.js +151 -0
  27. package/dist/domain/yields.js.map +1 -0
  28. package/dist/facade/calls.d.ts +59 -0
  29. package/dist/facade/calls.js +89 -0
  30. package/dist/facade/calls.js.map +1 -0
  31. package/dist/facade/chain-configs.js +5 -0
  32. package/dist/facade/chain-configs.js.map +1 -1
  33. package/dist/facade/flows.d.ts +24 -3
  34. package/dist/facade/flows.js +22 -2
  35. package/dist/facade/flows.js.map +1 -1
  36. package/dist/facade/index.d.ts +3 -1
  37. package/dist/facade/index.js +1 -0
  38. package/dist/facade/index.js.map +1 -1
  39. package/dist/facade/kasu.d.ts +8 -2
  40. package/dist/facade/kasu.js +17 -5
  41. package/dist/facade/kasu.js.map +1 -1
  42. package/dist/facade/strategies.d.ts +43 -2
  43. package/dist/facade/strategies.js +80 -4
  44. package/dist/facade/strategies.js.map +1 -1
  45. package/dist/facade/types.d.ts +18 -0
  46. package/dist/flows/batch-errors.d.ts +74 -0
  47. package/dist/flows/batch-errors.js +83 -0
  48. package/dist/flows/batch-errors.js.map +1 -0
  49. package/dist/flows/batch-submission.d.ts +223 -0
  50. package/dist/flows/batch-submission.js +321 -0
  51. package/dist/flows/batch-submission.js.map +1 -0
  52. package/dist/flows/deposit-flow.d.ts +13 -20
  53. package/dist/flows/deposit-flow.js +14 -25
  54. package/dist/flows/deposit-flow.js.map +1 -1
  55. package/dist/flows/index.d.ts +6 -2
  56. package/dist/flows/index.js +4 -2
  57. package/dist/flows/index.js.map +1 -1
  58. package/dist/flows/withdraw-flow.d.ts +95 -4
  59. package/dist/flows/withdraw-flow.js +107 -2
  60. package/dist/flows/withdraw-flow.js.map +1 -1
  61. package/dist/services/DataService/queries.js +1 -0
  62. package/dist/services/DataService/queries.js.map +1 -1
  63. package/dist/services/UserLending/user-lending.d.ts +13 -0
  64. package/dist/services/UserLending/user-lending.js +25 -4
  65. package/dist/services/UserLending/user-lending.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/domain/batch-proof.ts +576 -0
  68. package/src/domain/explorer-urls.ts +74 -0
  69. package/src/domain/fixed-term.ts +115 -0
  70. package/src/domain/index.ts +56 -0
  71. package/src/domain/partners.ts +35 -0
  72. package/src/domain/requests.ts +37 -0
  73. package/src/domain/sponsored-calls.ts +213 -0
  74. package/src/domain/yields.ts +189 -0
  75. package/src/facade/calls.ts +135 -0
  76. package/src/facade/chain-configs.ts +5 -0
  77. package/src/facade/flows.ts +23 -2
  78. package/src/facade/index.ts +3 -0
  79. package/src/facade/kasu.ts +31 -2
  80. package/src/facade/strategies.ts +98 -7
  81. package/src/facade/types.ts +19 -0
  82. package/src/flows/batch-errors.ts +118 -0
  83. package/src/flows/batch-submission.ts +511 -0
  84. package/src/flows/deposit-flow.ts +27 -33
  85. package/src/flows/index.ts +33 -2
  86. package/src/flows/withdraw-flow.ts +219 -10
  87. package/src/services/DataService/queries.ts +1 -0
  88. package/src/services/UserLending/user-lending.ts +38 -10
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The errors an atomic-batch submitter throws, in one module both
3
+ * `DepositFlow` and `submitBatch` import — so `instanceof` is one class
4
+ * whichever of them a consumer imports it through.
5
+ */
6
+
7
+ /**
8
+ * What a `depositBatch` port throws when it KNOWS the batch reverted — from a
9
+ * simulation before sending, from the submitter refusing to broadcast a batch
10
+ * that would revert, or from a mined receipt whose user operation reverted.
11
+ * The run ends `{ step: 'request', reason: 'reverted', revertError }`.
12
+ *
13
+ * `revertError` is the custom error the contract named (`'ClearingIsPending'`,
14
+ * `'UserNotKycd'`, …), exactly as the ABI declares it. Pass `''` when the
15
+ * batch is known to revert but not why: the consumer then renders its generic
16
+ * `reverted` copy, which is what an unknown name gets anyway.
17
+ *
18
+ * A revert is DEFINITIVE — nothing was deposited. Throw it only when that is
19
+ * known; anything less certain is a `BatchPendingError`.
20
+ */
21
+ export class BatchRevertedError extends Error {
22
+ readonly revertError: string;
23
+
24
+ constructor(revertError: string, message?: string) {
25
+ super(
26
+ message ??
27
+ `DepositFlow: the batch reverted${
28
+ revertError ? ` with ${revertError}` : ''
29
+ }`,
30
+ );
31
+ this.name = 'BatchRevertedError';
32
+ this.revertError = revertError;
33
+ // Keeps `instanceof` honest under a down-levelled build.
34
+ Object.setPrototypeOf(this, BatchRevertedError.prototype);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * What became of a batch submission that did NOT end in a proven result and is
40
+ * not a known revert:
41
+ *
42
+ * - `failed` — nothing was sent and nothing can land: refused before the
43
+ * batch left (no session, a validation mismatch, a definitive refusal).
44
+ * "Please try again" is true.
45
+ * - `pending` — the batch was, or may have been, accepted, and nothing says
46
+ * whether it deposited. Calling that "failed" invites a second deposit; the
47
+ * lender is told it may still be processing.
48
+ * - `already-submitted` — an earlier submission has since been proven, so this
49
+ * one is refused rather than lend twice.
50
+ */
51
+ export type BatchSubmissionOutcome = 'failed' | 'pending' | 'already-submitted';
52
+
53
+ /**
54
+ * Every non-revert failure of a batch submission, carrying its outcome and a
55
+ * short code (`'status_timeout'`, `'receipt_unavailable'`, …).
56
+ *
57
+ * The message is fixed and names no party: the wallet-error classifier reads a
58
+ * thrown error's text for a USER rejection, and a relay outage worded "…wallet
59
+ * refused…" must never reach the lender as something they did.
60
+ *
61
+ * `outcome` has NO default. `failed` is the one outcome that invites a second
62
+ * deposit, so a port author has to spell it out — a `throw new
63
+ * BatchSubmissionError(code)` written in a catch block after the POST left
64
+ * must not quietly become "try again".
65
+ */
66
+ export class BatchSubmissionError extends Error {
67
+ readonly code: string;
68
+ readonly outcome: BatchSubmissionOutcome;
69
+
70
+ constructor(
71
+ code: string,
72
+ outcome: BatchSubmissionOutcome,
73
+ options?: { cause?: unknown },
74
+ ) {
75
+ super(`Batch submission ${outcome} (${code})`);
76
+ this.name = 'BatchSubmissionError';
77
+ this.code = code;
78
+ this.outcome = outcome;
79
+ // Set by hand: `Error`'s own `cause` option is ES2022.
80
+ if (options && 'cause' in options) {
81
+ Object.defineProperty(this, 'cause', {
82
+ value: options.cause,
83
+ enumerable: false,
84
+ writable: true,
85
+ configurable: true,
86
+ });
87
+ }
88
+ Object.setPrototypeOf(this, BatchSubmissionError.prototype);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The ambiguous outcome: the batch may have landed. An instance of
94
+ * `BatchSubmissionError` with `outcome: 'pending'`.
95
+ */
96
+ export class BatchPendingError extends BatchSubmissionError {
97
+ constructor(code: string, options?: { cause?: unknown }) {
98
+ super(code, 'pending', options);
99
+ this.name = 'BatchPendingError';
100
+ Object.setPrototypeOf(this, BatchPendingError.prototype);
101
+ }
102
+ }
103
+
104
+ /** The outcome a thrown error stands for, or `null` if it is not a submission error. */
105
+ export function batchOutcomeOf(err: unknown): BatchSubmissionOutcome | null {
106
+ return err instanceof BatchSubmissionError ? err.outcome : null;
107
+ }
108
+
109
+ /**
110
+ * Nothing was deposited and nothing can be: a known revert, or a definitive
111
+ * `failed` refusal. Everything else — including an error this SDK did not
112
+ * produce — is NOT proof of that, and a consumer must not invite a retry on it.
113
+ */
114
+ export function isDefinitivelyNotSubmitted(err: unknown): boolean {
115
+ return (
116
+ err instanceof BatchRevertedError || batchOutcomeOf(err) === 'failed'
117
+ );
118
+ }
@@ -0,0 +1,511 @@
1
+ import {
2
+ DepositProof,
3
+ DepositProofExpectation,
4
+ proveDepositInReceipt,
5
+ proveUserOperationInReceipt,
6
+ ReceiptLike,
7
+ UserOperationProof,
8
+ UserOperationProofExpectation,
9
+ } from '../domain/batch-proof';
10
+ import {
11
+ SPONSORED_CALL_KINDS,
12
+ SPONSORED_CALL_SELECTORS,
13
+ SponsoredCall,
14
+ } from '../domain/sponsored-calls';
15
+
16
+ import {
17
+ BatchPendingError,
18
+ BatchRevertedError,
19
+ BatchSubmissionError,
20
+ } from './batch-errors';
21
+ import { WaitableTransaction } from './observable';
22
+
23
+ /**
24
+ * Submitting a sponsored atomic deposit batch, from the accepted calls to a
25
+ * PROVEN deposit — the polling policy and the outcome taxonomy, and nothing
26
+ * else. No relay vendor, no URL, no wallet: the app's ports do the I/O, and
27
+ * this decides what each answer MEANS.
28
+ *
29
+ * The one rule everything here serves: **an ambiguous result is never
30
+ * reported as failed.** "Failed" tells a lender to try again, and a batch that
31
+ * may already have moved the money must never invite a second deposit.
32
+ */
33
+
34
+ /** One call of the batch. Structurally `BatchCall`. */
35
+ export interface SubmitBatchCall {
36
+ to: string;
37
+ data: string;
38
+ }
39
+
40
+ /**
41
+ * One read of the submitted batch's status, already NORMALISED by the app's
42
+ * port — the vendor's status vocabulary stays in the app.
43
+ */
44
+ export type BatchStatusRead =
45
+ /** Mined: a transaction hash exists. */
46
+ | { kind: 'mined'; txHash: string; userOperationHash?: string | null }
47
+ /** The vendor says, definitively, that it reverted: nothing deposited. */
48
+ | { kind: 'reverted' }
49
+ /**
50
+ * Final for the vendor but silent about the deposit, or the status read
51
+ * was refused: stop polling, outcome UNKNOWN (→ pending).
52
+ */
53
+ | { kind: 'unknown'; code: string }
54
+ /** Not there yet, replaced, or a transient failure: ask again. */
55
+ | { kind: 'again'; userOperationHash?: string | null };
56
+
57
+ export interface BatchSubmissionPorts<R extends ReceiptLike = ReceiptLike> {
58
+ /**
59
+ * Hand the batch to the submitter; resolve with its transaction id once it
60
+ * is ACCEPTED.
61
+ *
62
+ * Throw `BatchRevertedError` when it is known to revert, and
63
+ * `BatchSubmissionError(code, 'failed')` ONLY when nothing was sent — no
64
+ * session, a refusal before broadcast. Any other throw is read as
65
+ * PENDING: this cannot know whether an unrecognised error left before or
66
+ * after the batch was accepted. Resending the same signed request after an
67
+ * ambiguous answer is the port's business (it owns the wire protocol).
68
+ */
69
+ post(calls: readonly SubmitBatchCall[]): Promise<{ transactionId: string }>;
70
+ /** One status read. A throw is treated as `{ kind: 'again' }`. */
71
+ getStatus(transactionId: string): Promise<BatchStatusRead>;
72
+ /**
73
+ * The mined receipt. Bound it with a timeout of your own; a throw is
74
+ * PENDING (`receipt_unavailable`) — the batch was mined, its receipt was
75
+ * just not readable.
76
+ */
77
+ getReceipt(txHash: string): Promise<R>;
78
+ /** ms epoch. */
79
+ now(): number;
80
+ sleep(ms: number): Promise<void>;
81
+ }
82
+
83
+ export interface BatchPollingPolicy {
84
+ /** Delay before the second status read (the first is immediate). */
85
+ initialMs: number;
86
+ /** Ceiling on the delay between reads. */
87
+ maxMs: number;
88
+ /** Multiplier applied to the delay after each read, ≥ 1. */
89
+ backoff: number;
90
+ /** Stop polling — PENDING, never failed — after this long. */
91
+ timeoutMs: number;
92
+ }
93
+
94
+ /**
95
+ * First read at once, then 3 s backing off ×1.5 to a 10 s ceiling, for up to
96
+ * 10 minutes. Running out of time is PENDING: the batch was accepted and may
97
+ * still land.
98
+ */
99
+ export const DEFAULT_BATCH_POLLING_POLICY: Readonly<BatchPollingPolicy> =
100
+ Object.freeze({
101
+ initialMs: 3_000,
102
+ maxMs: 10_000,
103
+ backoff: 1.5,
104
+ timeoutMs: 10 * 60 * 1000,
105
+ });
106
+
107
+ /** A proven deposit: the `deposited` proof and the receipt it was read from. */
108
+ export interface ProvenBatch<R extends ReceiptLike = ReceiptLike> {
109
+ proof: Extract<DepositProof, { kind: 'deposited' }>;
110
+ receipt: R;
111
+ txHash: string;
112
+ }
113
+
114
+ /** What `submitBatch` resolves with once the batch is accepted. */
115
+ export interface BatchSubmission<R extends ReceiptLike = ReceiptLike>
116
+ extends WaitableTransaction {
117
+ transactionId: string;
118
+ /**
119
+ * Polls to a mined receipt and proves the deposit in it. Resolves ONLY on
120
+ * a proven deposit. Rejects with `BatchRevertedError` when it definitively
121
+ * did not deposit, and with `BatchPendingError` for everything else.
122
+ */
123
+ wait(): Promise<ProvenBatch<R>>;
124
+ }
125
+
126
+ function resolvePolicy(
127
+ policy: Partial<BatchPollingPolicy> | undefined,
128
+ ): BatchPollingPolicy {
129
+ const resolved: BatchPollingPolicy = {
130
+ initialMs: policy?.initialMs ?? DEFAULT_BATCH_POLLING_POLICY.initialMs,
131
+ maxMs: policy?.maxMs ?? DEFAULT_BATCH_POLLING_POLICY.maxMs,
132
+ backoff: policy?.backoff ?? DEFAULT_BATCH_POLLING_POLICY.backoff,
133
+ timeoutMs: policy?.timeoutMs ?? DEFAULT_BATCH_POLLING_POLICY.timeoutMs,
134
+ };
135
+ const { initialMs, maxMs, backoff, timeoutMs } = resolved;
136
+ if (
137
+ !(Number.isFinite(initialMs) && initialMs > 0) ||
138
+ !(Number.isFinite(maxMs) && maxMs >= initialMs) ||
139
+ !(Number.isFinite(backoff) && backoff >= 1) ||
140
+ !(Number.isFinite(timeoutMs) && timeoutMs > 0)
141
+ ) {
142
+ throw new TypeError('submitBatch: invalid polling policy');
143
+ }
144
+ return resolved;
145
+ }
146
+
147
+ /**
148
+ * Submit `calls` and return once the submitter ACCEPTED them; `wait()` then
149
+ * polls and proves. Use it as the body of a `DepositFlow` `depositBatch`
150
+ * port, after `validateDepositBatchCalls` and the app's own signing:
151
+ *
152
+ * ```ts
153
+ * depositBatch: (calls) => {
154
+ * const { tranche, amount } = validateDepositBatchCalls(calls, expected);
155
+ * return submitBatch(
156
+ * // `pendingPool`: the strategy's pending pool, so only ITS
157
+ * // `DepositRequested` can prove the deposit.
158
+ * { calls, expect: { lender, tranche, amount, pendingPool } },
159
+ * { post, getStatus, getReceipt, now: Date.now, sleep },
160
+ * );
161
+ * },
162
+ * ```
163
+ *
164
+ * | what happened | outcome |
165
+ * | ---------------------------------------------------- | ---------------------------------------- |
166
+ * | `post` throws `BatchRevertedError` | rethrown — reverted, nothing deposited |
167
+ * | `post` throws `BatchSubmissionError` | rethrown with its own outcome |
168
+ * | `post` throws anything else | `BatchPendingError('post_unknown')` |
169
+ * | `post` resolves without a transaction id | `BatchPendingError('malformed_answer')` |
170
+ * | status `reverted` | `BatchRevertedError('')` |
171
+ * | status `unknown` | `BatchPendingError(code)` |
172
+ * | polling runs out of time | `BatchPendingError('status_timeout')` |
173
+ * | receipt unreadable | `BatchPendingError('receipt_unavailable')` |
174
+ * | receipt: user operation reverted | `BatchRevertedError(revertError)` |
175
+ * | receipt: this deposit not shown | `BatchPendingError('deposit_unproven:<reason>')` |
176
+ * | receipt: this lender's deposit, tranche and amount | resolves `ProvenBatch` |
177
+ *
178
+ * Nothing on this path ever produces a `failed` outcome: by the time a batch
179
+ * has been posted, only a revert proves that nothing was deposited.
180
+ */
181
+ export async function submitBatch<R extends ReceiptLike = ReceiptLike>(
182
+ input: {
183
+ calls: readonly SubmitBatchCall[];
184
+ /** What the receipt must show — see `proveDepositInReceipt`. */
185
+ expect: Omit<DepositProofExpectation, 'userOperationHash'>;
186
+ },
187
+ ports: BatchSubmissionPorts<R>,
188
+ policy?: Partial<BatchPollingPolicy>,
189
+ ): Promise<BatchSubmission<R>> {
190
+ const resolved = resolvePolicy(policy);
191
+ const transactionId = await postOnce(() => ports.post(input.calls));
192
+
193
+ let settled: Promise<ProvenBatch<R>> | null = null;
194
+ return {
195
+ transactionId,
196
+ // Memoised: a second `wait()` reads the same outcome instead of
197
+ // polling the submitter twice.
198
+ wait: (): Promise<ProvenBatch<R>> =>
199
+ (settled ??= settle(transactionId, input.expect, ports, resolved)),
200
+ };
201
+ }
202
+
203
+ /**
204
+ * The post, and what its answer means: the transaction id, or the error the
205
+ * outcome table names. Shared by both submitters, so the two cannot come to
206
+ * disagree about which throws are ambiguous.
207
+ */
208
+ async function postOnce(
209
+ post: () => Promise<{ transactionId: string }>,
210
+ ): Promise<string> {
211
+ let accepted: { transactionId: string };
212
+ try {
213
+ accepted = await post();
214
+ } catch (err) {
215
+ if (err instanceof BatchRevertedError) throw err;
216
+ if (err instanceof BatchSubmissionError) throw err;
217
+ throw new BatchPendingError('post_unknown', { cause: err });
218
+ }
219
+ const transactionId = (accepted as { transactionId?: unknown } | null)
220
+ ?.transactionId;
221
+ if (typeof transactionId !== 'string' || transactionId === '') {
222
+ throw new BatchPendingError('malformed_answer');
223
+ }
224
+ return transactionId;
225
+ }
226
+
227
+ async function readStatus<R extends ReceiptLike>(
228
+ ports: Pick<BatchSubmissionPorts<R>, 'getStatus'>,
229
+ transactionId: string,
230
+ ): Promise<BatchStatusRead> {
231
+ try {
232
+ return await ports.getStatus(transactionId);
233
+ } catch {
234
+ return { kind: 'again' };
235
+ }
236
+ }
237
+
238
+ /** Poll until a mined hash, or a typed reason there will not be one. */
239
+ async function waitForMined<R extends ReceiptLike>(
240
+ transactionId: string,
241
+ ports: Pick<BatchSubmissionPorts<R>, 'getStatus' | 'now' | 'sleep'>,
242
+ policy: BatchPollingPolicy,
243
+ ): Promise<{ txHash: string; userOperationHash: string | null }> {
244
+ const deadline = ports.now() + policy.timeoutMs;
245
+ let userOperationHash: string | null = null;
246
+ let delay = policy.initialMs;
247
+
248
+ for (;;) {
249
+ const read = await readStatus(ports, transactionId);
250
+ switch (read.kind) {
251
+ case 'mined':
252
+ if (typeof read.txHash !== 'string' || read.txHash === '') {
253
+ break; // a malformed "mined" is not an answer; ask again
254
+ }
255
+ return {
256
+ txHash: read.txHash,
257
+ userOperationHash:
258
+ read.userOperationHash ?? userOperationHash,
259
+ };
260
+ case 'reverted':
261
+ throw new BatchRevertedError('');
262
+ case 'unknown':
263
+ throw new BatchPendingError(read.code);
264
+ case 'again':
265
+ if (read.userOperationHash) {
266
+ userOperationHash = read.userOperationHash;
267
+ }
268
+ break;
269
+ default:
270
+ // A read the port did not normalise: not an answer.
271
+ break;
272
+ }
273
+ if (ports.now() + delay > deadline) {
274
+ throw new BatchPendingError('status_timeout');
275
+ }
276
+ await ports.sleep(delay);
277
+ delay = Math.min(policy.maxMs, Math.round(delay * policy.backoff));
278
+ }
279
+ }
280
+
281
+ /** Poll to a mined hash and read its receipt; a receipt read that throws is PENDING. */
282
+ async function minedReceipt<R extends ReceiptLike>(
283
+ transactionId: string,
284
+ ports: BatchSubmissionPorts<R> | SponsoredCallSubmissionPorts<R>,
285
+ policy: BatchPollingPolicy,
286
+ ): Promise<{ receipt: R; txHash: string; userOperationHash: string | null }> {
287
+ const mined = await waitForMined(transactionId, ports, policy);
288
+ try {
289
+ return {
290
+ receipt: await ports.getReceipt(mined.txHash),
291
+ txHash: mined.txHash,
292
+ userOperationHash: mined.userOperationHash,
293
+ };
294
+ } catch (err) {
295
+ throw new BatchPendingError('receipt_unavailable', { cause: err });
296
+ }
297
+ }
298
+
299
+ async function settle<R extends ReceiptLike>(
300
+ transactionId: string,
301
+ expect: Omit<DepositProofExpectation, 'userOperationHash'>,
302
+ ports: BatchSubmissionPorts<R>,
303
+ policy: BatchPollingPolicy,
304
+ ): Promise<ProvenBatch<R>> {
305
+ const mined = await minedReceipt(transactionId, ports, policy);
306
+ let proof: DepositProof;
307
+ try {
308
+ proof = proveDepositInReceipt(mined.receipt, {
309
+ ...expect,
310
+ userOperationHash: mined.userOperationHash,
311
+ });
312
+ } catch (err) {
313
+ // A receipt the proof cannot even read proves nothing either way.
314
+ throw new BatchPendingError('deposit_unproven', { cause: err });
315
+ }
316
+ if (proof.kind === 'reverted')
317
+ throw new BatchRevertedError(proof.revertError);
318
+ if (proof.kind === 'unproven') {
319
+ // The proof's reason rides in the code: "no-user-operation" (the hash
320
+ // is not this lender's operation at all) and "no-deposit-requested"
321
+ // (the operation ran and shows no deposit) are the two things an
322
+ // operator reading the alert needs to tell apart first.
323
+ throw new BatchPendingError(`deposit_unproven:${proof.reason}`);
324
+ }
325
+ return { proof, receipt: mined.receipt, txHash: mined.txHash };
326
+ }
327
+
328
+ /**
329
+ * What became of an earlier submission that ended PENDING — ONE status read,
330
+ * no polling — for an app's guard against lending twice:
331
+ *
332
+ * - `deposited` — mined, and the receipt proves the deposit;
333
+ * - `not-deposited` — definitively reverted (vendor status or the user
334
+ * operation's own flag);
335
+ * - `unknown` — anything else, including a read or receipt that failed.
336
+ *
337
+ * Only `not-deposited` may let a new lend proceed unprompted; `deposited`
338
+ * means the lender is told the earlier one landed (`already-submitted`).
339
+ */
340
+ export async function checkBatchOutcome<R extends ReceiptLike = ReceiptLike>(
341
+ transactionId: string,
342
+ expect: Omit<DepositProofExpectation, 'userOperationHash'>,
343
+ ports: Pick<BatchSubmissionPorts<R>, 'getStatus' | 'getReceipt'>,
344
+ ): Promise<'deposited' | 'not-deposited' | 'unknown'> {
345
+ let read: BatchStatusRead;
346
+ try {
347
+ read = await ports.getStatus(transactionId);
348
+ } catch {
349
+ return 'unknown';
350
+ }
351
+ if (read.kind === 'reverted') return 'not-deposited';
352
+ if (read.kind !== 'mined' || typeof read.txHash !== 'string') {
353
+ return 'unknown';
354
+ }
355
+ try {
356
+ const receipt = await ports.getReceipt(read.txHash);
357
+ const proof = proveDepositInReceipt(receipt, {
358
+ ...expect,
359
+ userOperationHash: read.userOperationHash ?? null,
360
+ });
361
+ if (proof.kind === 'deposited') return 'deposited';
362
+ if (proof.kind === 'reverted') return 'not-deposited';
363
+ return 'unknown';
364
+ } catch {
365
+ return 'unknown';
366
+ }
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // One sponsored call — withdraw, cancel, withdraw at expiry
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * `BatchSubmissionPorts` for ONE sponsored call: the same status, receipt and
375
+ * clock ports — a relay client written for the deposit batch serves both —
376
+ * with a `post` that receives the call together with its relay `kind`, so the
377
+ * app never maps one from the other.
378
+ */
379
+ export interface SponsoredCallSubmissionPorts<
380
+ R extends ReceiptLike = ReceiptLike,
381
+ > extends Omit<BatchSubmissionPorts<R>, 'post'> {
382
+ /**
383
+ * Hand the call to the submitter (`kind` and `[call]` in the relay's
384
+ * body); resolve with its transaction id once it is ACCEPTED. The same
385
+ * throw rules as `BatchSubmissionPorts.post`: `BatchRevertedError` when
386
+ * it is known to revert, `BatchSubmissionError(code, 'failed')` ONLY when
387
+ * nothing was sent, anything else is read as PENDING.
388
+ */
389
+ post(call: SponsoredCall): Promise<{ transactionId: string }>;
390
+ }
391
+
392
+ /** An executed call: the `executed` proof and the receipt it was read from. */
393
+ export interface ExecutedCall<R extends ReceiptLike = ReceiptLike> {
394
+ proof: Extract<UserOperationProof, { kind: 'executed' }>;
395
+ receipt: R;
396
+ txHash: string;
397
+ }
398
+
399
+ /** What `submitSponsoredCall` resolves with once the call is accepted. */
400
+ export interface SponsoredCallSubmission<R extends ReceiptLike = ReceiptLike>
401
+ extends WaitableTransaction {
402
+ transactionId: string;
403
+ /**
404
+ * Polls to a mined receipt and proves the sender's operation EXECUTED.
405
+ * Rejects with `BatchRevertedError` when it definitively did not, and
406
+ * with `BatchPendingError` for everything else.
407
+ */
408
+ wait(): Promise<ExecutedCall<R>>;
409
+ }
410
+
411
+ /**
412
+ * `submitBatch` for ONE sponsored `LendingPoolManager` call — the body of a
413
+ * `WithdrawFlow` `submitCall` port, and of an app's cancel or
414
+ * withdraw-at-expiry action. Same post, same polling policy, same outcome
415
+ * table; the proof is `proveUserOperationInReceipt` (the sender's operation
416
+ * succeeded) instead of the deposit proof, and an unproven receipt is
417
+ * `call_unproven:<reason>`.
418
+ *
419
+ * ```ts
420
+ * submitCall: (call) =>
421
+ * submitSponsoredCall(
422
+ * { call, expect: { sender: lender } },
423
+ * { post: (c) => postToMyRelay(c.kind, [c.call]), getStatus, getReceipt, now, sleep },
424
+ * ),
425
+ * ```
426
+ *
427
+ * The call is checked against its kind before anything is posted — a known
428
+ * kind, exactly `{ to, data }`, and `data` opening with that kind's selector —
429
+ * and a mismatch is `BatchSubmissionError('call_mismatch', 'failed')`: nothing
430
+ * was sent. Build calls with `kasu.calls`, and this never fires.
431
+ *
432
+ * As on the deposit path, nothing after the post ever produces `failed`.
433
+ */
434
+ export async function submitSponsoredCall<R extends ReceiptLike = ReceiptLike>(
435
+ input: {
436
+ call: SponsoredCall;
437
+ /** Whose operation must have executed — see `proveUserOperationInReceipt`. */
438
+ expect: Omit<UserOperationProofExpectation, 'userOperationHash'>;
439
+ },
440
+ ports: SponsoredCallSubmissionPorts<R>,
441
+ policy?: Partial<BatchPollingPolicy>,
442
+ ): Promise<SponsoredCallSubmission<R>> {
443
+ const resolved = resolvePolicy(policy);
444
+ if (!isWellFormedSponsoredCall(input.call)) {
445
+ throw new BatchSubmissionError('call_mismatch', 'failed');
446
+ }
447
+ const transactionId = await postOnce(() => ports.post(input.call));
448
+
449
+ let settled: Promise<ExecutedCall<R>> | null = null;
450
+ return {
451
+ transactionId,
452
+ wait: (): Promise<ExecutedCall<R>> =>
453
+ (settled ??= settleCall(
454
+ transactionId,
455
+ input.expect,
456
+ ports,
457
+ resolved,
458
+ )),
459
+ };
460
+ }
461
+
462
+ function isWellFormedSponsoredCall(value: unknown): value is SponsoredCall {
463
+ if (value === null || typeof value !== 'object') return false;
464
+ const { kind, call } = value as { kind?: unknown; call?: unknown };
465
+ if (
466
+ typeof kind !== 'string' ||
467
+ !(SPONSORED_CALL_KINDS as readonly string[]).includes(kind)
468
+ ) {
469
+ return false;
470
+ }
471
+ if (call === null || typeof call !== 'object') return false;
472
+ const keys = Object.keys(call);
473
+ if (keys.length !== 2 || !keys.includes('to') || !keys.includes('data')) {
474
+ return false;
475
+ }
476
+ const { to, data } = call as { to: unknown; data: unknown };
477
+ return (
478
+ typeof to === 'string' &&
479
+ /^0x[0-9a-fA-F]{40}$/.test(to) &&
480
+ typeof data === 'string' &&
481
+ /^0x(?:[0-9a-fA-F]{2})*$/.test(data) &&
482
+ data
483
+ .toLowerCase()
484
+ .startsWith(SPONSORED_CALL_SELECTORS[kind as SponsoredCall['kind']])
485
+ );
486
+ }
487
+
488
+ async function settleCall<R extends ReceiptLike>(
489
+ transactionId: string,
490
+ expect: Omit<UserOperationProofExpectation, 'userOperationHash'>,
491
+ ports: SponsoredCallSubmissionPorts<R>,
492
+ policy: BatchPollingPolicy,
493
+ ): Promise<ExecutedCall<R>> {
494
+ const mined = await minedReceipt(transactionId, ports, policy);
495
+ let proof: UserOperationProof;
496
+ try {
497
+ proof = proveUserOperationInReceipt(mined.receipt, {
498
+ ...expect,
499
+ userOperationHash: mined.userOperationHash,
500
+ });
501
+ } catch (err) {
502
+ throw new BatchPendingError('call_unproven', { cause: err });
503
+ }
504
+ if (proof.kind === 'reverted') {
505
+ throw new BatchRevertedError(proof.revertError);
506
+ }
507
+ if (proof.kind === 'unproven') {
508
+ throw new BatchPendingError(`call_unproven:${proof.reason}`);
509
+ }
510
+ return { proof, receipt: mined.receipt, txHash: mined.txHash };
511
+ }