@kasufinance/kasu-sdk 2.7.0 → 2.8.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.
@@ -1,5 +1,7 @@
1
- import { BigNumber } from 'ethers';
1
+ import { BigNumber, utils } from 'ethers';
2
2
 
3
+ import { IERC20MetadataAbi__factory } from '../contracts/factories/IERC20MetadataAbi__factory';
4
+ import { ILendingPoolManagerAbi__factory } from '../contracts/factories/ILendingPoolManagerAbi__factory';
3
5
  import {
4
6
  asContractType,
5
7
  buildLegacyContractRequestMessage,
@@ -7,6 +9,7 @@ import {
7
9
  encodeDepositData,
8
10
  GenerateContractResponse,
9
11
  } from '../domain/loan-contract';
12
+ import { decodeRevert } from '../domain/revert-errors';
10
13
  import {
11
14
  classifyWalletFailure,
12
15
  isUnpredictableGas,
@@ -58,16 +61,41 @@ import { WaitableTransaction } from './observable';
58
61
  * 8. **`request-sign` / `request-confirm`** — KYC signature, then the deposit,
59
62
  * then the receipt.
60
63
  *
64
+ * ## The batch path
65
+ *
66
+ * A consumer that can submit several calls as ONE atomic transaction supplies
67
+ * the optional `depositBatch` port. When the allowance is short, that port is
68
+ * present and the stable token is known, steps 7 and 8 become one: the SDK
69
+ * encodes `approve(spender, amount)` and `requestDepositWithKyc(...)` itself
70
+ * and hands both to the port. Because a batch has no wallet prompt per call,
71
+ * the run first PARKS on `awaiting-authorise` until the consumer calls
72
+ * `authoriseTransfer()` (or `declineTransfer()`) — nothing moves money
73
+ * without an explicit authorisation. The TTL is checked again after it, and
74
+ * the KYC signature is fetched only then, because it carries a block
75
+ * expiration of its own. Without the port, or with the allowance already
76
+ * sufficient, the run is exactly the two-transaction pipeline above.
77
+ *
61
78
  * ## Failure codes
62
79
  *
63
80
  * A wallet rejection (`isUserRejected`) is `'cancelled'` — the lender changed
64
81
  * their mind, and telling them something broke would be a lie. Only a WALLET
65
82
  * call is ever classified that way: the HTTP ports are always `'failed'`,
66
83
  * because a backend that words a refusal "declined" did not involve the
67
- * lender's wallet. A reverted gas estimate on the request step
68
- * (`isUnpredictableGas`) is `'insufficient-balance'` — nothing was refused, the
69
- * transaction simply cannot succeed as composed. Everything else is `'failed'`
70
- * and carries the original error for the consumer's crash reporter.
84
+ * lender's wallet. A revert on the request step is read for WHAT reverted: a
85
+ * protocol error the ABI declares is `'reverted'` with the name, and only a
86
+ * token balance or allowance failure — or a revert nothing can decode — stays
87
+ * `'insufficient-balance'`. Everything else is `'failed'` and carries the
88
+ * original error for the consumer's crash reporter.
89
+ *
90
+ * ## Both signatures are checked before they are used
91
+ *
92
+ * A wallet is not obliged to hand back 65 bytes, and `encodeDepositData` will
93
+ * not catch one that does not: `defaultAbiCoder` encodes any even-length hex
94
+ * as `bytes`, `0x` included, so a malformed acceptance signature becomes a
95
+ * `depositData` blob that is broadcast, mined, and then can never be verified
96
+ * by the agreements service. Both personal signs are therefore length-checked
97
+ * where they are taken, and a bad one fails the step it was taken on rather
98
+ * than the one it would eventually have broken.
71
99
  *
72
100
  * ```ts
73
101
  * const flow = new DepositFlow(ports);
@@ -93,6 +121,11 @@ export type DepositPhase =
93
121
  | 'awaiting-accept'
94
122
  | 'accepting-sign'
95
123
  | 'approve'
124
+ /**
125
+ * Batch path only: the run is PARKED until `authoriseTransfer()` or
126
+ * `declineTransfer()`. Step `request`.
127
+ */
128
+ | 'awaiting-authorise'
96
129
  | 'request-sign'
97
130
  | 'request-confirm'
98
131
  | 'success'
@@ -117,6 +150,23 @@ export type DepositFailure =
117
150
  | { step: DepositStep; reason: 'cancelled' }
118
151
  | { step: DepositStep; reason: 'failed'; error: unknown }
119
152
  | { step: 'request'; reason: 'insufficient-balance'; error: unknown }
153
+ | {
154
+ step: 'request';
155
+ reason: 'reverted';
156
+ /**
157
+ * The custom error the contract reverted with, exactly as the ABI
158
+ * declares it — `'ClearingIsPending'`, `'LendingPoolIsStopped'`,
159
+ * `'UserNotKycd'`, and the rest of
160
+ * `ILendingPoolManagerAbi` / `IKasuAllowListAbi`.
161
+ *
162
+ * Render `reverted` with generic copy and special-case only the
163
+ * names you have words for: a contract upgrade can add an error, and
164
+ * a consumer that assumed the set was closed would have nothing to
165
+ * show for the new one.
166
+ */
167
+ revertError: string;
168
+ error: unknown;
169
+ }
120
170
  | { step: 'request'; reason: 'contract-expired' };
121
171
 
122
172
  // ---------------------------------------------------------------------------
@@ -125,6 +175,48 @@ export type DepositFailure =
125
175
 
126
176
  export type { WaitableTransaction };
127
177
 
178
+ /**
179
+ * One call of an atomic batch: a target and its ABI-encoded calldata. No
180
+ * value — neither call the deposit batch makes sends the native token.
181
+ */
182
+ export interface BatchCall {
183
+ to: string;
184
+ data: string;
185
+ }
186
+
187
+ /**
188
+ * What a `depositBatch` port throws when it KNOWS the batch reverted — from a
189
+ * simulation before sending, or from the submitter refusing to broadcast a
190
+ * batch that would revert. The run ends `{ step: 'request', reason:
191
+ * 'reverted', revertError }`.
192
+ *
193
+ * `revertError` is the custom error the contract named (`'ClearingIsPending'`,
194
+ * `'UserNotKycd'`, …), exactly as the ABI declares it. Pass `''` when the
195
+ * batch is known to revert but not why: the consumer then renders its generic
196
+ * `reverted` copy, which is what an unknown name gets anyway.
197
+ *
198
+ * Any OTHER throw from the port is read the way a direct deposit's is — a
199
+ * wallet rejection is `cancelled`, a decodable revert is `reverted`, and
200
+ * everything else is `failed` with the error kept. A port that cannot tell is
201
+ * better off throwing what it has than guessing a name.
202
+ */
203
+ export class BatchRevertedError extends Error {
204
+ readonly revertError: string;
205
+
206
+ constructor(revertError: string, message?: string) {
207
+ super(
208
+ message ??
209
+ `DepositFlow: the batch reverted${
210
+ revertError ? ` with ${revertError}` : ''
211
+ }`,
212
+ );
213
+ this.name = 'BatchRevertedError';
214
+ this.revertError = revertError;
215
+ // Keeps `instanceof` honest under a down-levelled build.
216
+ Object.setPrototypeOf(this, BatchRevertedError.prototype);
217
+ }
218
+ }
219
+
128
220
  /** What the KYC signing service hands back. */
129
221
  export interface KycSignature {
130
222
  signature: string;
@@ -228,11 +320,35 @@ export interface DepositPorts {
228
320
  approve(spender: string, amount: BigNumber): Promise<WaitableTransaction>;
229
321
  /** `requestDepositWithKyc`. Defaults to `kasu.deposits.deposit`. */
230
322
  deposit(params: DepositParams): Promise<WaitableTransaction>;
323
+ /**
324
+ * OPTIONAL, and with no default: submit several calls as ONE atomic
325
+ * transaction — all of them apply, or none does. How is the consumer's
326
+ * business; the SDK owns what goes in and in which order.
327
+ *
328
+ * When supplied, the allowance is short and the flow knows the stable
329
+ * token (`DepositFlowOptions.stableAsset`), the run takes the batch path:
330
+ * it parks on `awaiting-authorise`, then hands this port exactly two calls
331
+ * —
332
+ *
333
+ * 0. `stableAsset.approve(spender, amount)` — the EXACT deposit amount,
334
+ * never `MaxUint256`;
335
+ * 1. `spender.requestDepositWithKyc(pool, tranche, amount, '0x',
336
+ * fixedTermConfigId, depositData, { blockExpiration, signature })` —
337
+ * the call the default `deposit` port makes.
338
+ *
339
+ * and neither `approve` nor `deposit` is called. Resolve with something
340
+ * whose `wait()` settles when the batch is mined. Throw
341
+ * `BatchRevertedError` when the batch is known to revert.
342
+ */
343
+ depositBatch?(calls: BatchCall[]): Promise<WaitableTransaction>;
231
344
  /** ms-epoch clock. Defaults to `Date.now`; injected so the TTL is testable. */
232
345
  now?(): number;
233
346
  }
234
347
 
235
- /** Construction options. `kasu.flows.deposit()` fills `spender` in. */
348
+ /**
349
+ * Construction options. `kasu.flows.deposit()` fills `spender` and
350
+ * `stableAsset` in.
351
+ */
236
352
  export interface DepositFlowOptions {
237
353
  /** Agreement validity window; defaults to `CONTRACT_TTL_MS`. */
238
354
  contractTtlMs?: number;
@@ -243,6 +359,17 @@ export interface DepositFlowOptions {
243
359
  * deposit port calls.
244
360
  */
245
361
  spender?: string;
362
+ /**
363
+ * The ERC-20 the deposit is paid in — the target of the batch path's
364
+ * approve call. `kasu.flows.deposit()` passes this chain's
365
+ * `stableAsset.address`.
366
+ *
367
+ * Without it the batch path is never taken, even when `depositBatch` is
368
+ * supplied: an approve encoded against a guessed token is an approval
369
+ * granted on the wrong contract inside a transaction that then reverts.
370
+ * The run falls back to the `approve` and `deposit` ports instead.
371
+ */
372
+ stableAsset?: string;
246
373
  }
247
374
 
248
375
  // ---------------------------------------------------------------------------
@@ -286,10 +413,24 @@ export interface DepositState {
286
413
  step: DepositStep | null;
287
414
  /** 1-based badge position of `step`; `0` while idle. */
288
415
  stepIndex: number;
289
- /** `4`, or `3` when the allowance already covers the deposit. */
416
+ /**
417
+ * `4`, or `3` when the allowance already covers the deposit or the run
418
+ * is batched.
419
+ */
290
420
  stepTotal: number;
291
- /** Whether the approve step is in scope for this run. */
421
+ /**
422
+ * Whether the separate approve STEP is in scope for this run. `false`
423
+ * on a batched run too: its approval rides inside the request step's
424
+ * single transaction, so it never enters `approve` and counts as a
425
+ * three-step run on every badge.
426
+ */
292
427
  approvalRequired: boolean;
428
+ /**
429
+ * Whether this run approves and deposits as ONE atomic batch through the
430
+ * `depositBatch` port. Decided by the allowance pre-check, before the
431
+ * first signature, and `false` on every two-transaction run.
432
+ */
433
+ batched: boolean;
293
434
  /** The generated agreement, from `generating-fetch` onwards. */
294
435
  contract: GenerateContractResponse | null;
295
436
  /** Set with `phase: 'error'`, cleared by `reset()`. */
@@ -303,12 +444,34 @@ export const CONTRACT_TTL_MS = 5 * 60 * 1000;
303
444
  export const NO_SPENDER_MESSAGE =
304
445
  'DepositFlow: no ERC-20 spender; build the flow with kasu.flows.deposit() or pass `spender` on the input';
305
446
 
447
+ /** What a malformed auth signature fails the generate step with. */
448
+ export const INVALID_AUTH_SIGNATURE_MESSAGE =
449
+ 'DepositFlow: the wallet returned a malformed authentication signature; expected 65 bytes of 0x-prefixed hex';
450
+
451
+ /** What a malformed acceptance signature fails the confirm step with. */
452
+ export const INVALID_ACCEPTANCE_SIGNATURE_MESSAGE =
453
+ 'DepositFlow: the wallet returned a malformed acceptance signature; expected 65 bytes of 0x-prefixed hex';
454
+
455
+ /**
456
+ * Is this an EIP-191 personal sign, in the only shape the protocol accepts?
457
+ *
458
+ * 65 bytes — `r`, `s`, `v` — 0x-prefixed. Nothing downstream checks it: the
459
+ * ABI coder takes any even-length hex as `bytes`, so `'0x'` and a 64-byte
460
+ * string both encode happily into a `depositData` the agreements service then
461
+ * cannot verify against anything. The check has to happen where the signature
462
+ * is taken, which is here.
463
+ */
464
+ function isPersonalSignature(signature: unknown): signature is string {
465
+ return utils.isHexString(signature, 65);
466
+ }
467
+
306
468
  const INITIAL: DepositState = {
307
469
  phase: 'idle',
308
470
  step: null,
309
471
  stepIndex: 0,
310
472
  stepTotal: 4,
311
473
  approvalRequired: true,
474
+ batched: false,
312
475
  contract: null,
313
476
  failure: null,
314
477
  };
@@ -334,6 +497,56 @@ type AcceptOutcome =
334
497
  /** `reset()` unparked it. The run's token check drops everything after. */
335
498
  | { kind: 'abandoned' };
336
499
 
500
+ /** How the batch path's authorisation park ends. Same rules as `AcceptOutcome`. */
501
+ type AuthoriseOutcome =
502
+ | { kind: 'authorised' }
503
+ | { kind: 'declined' }
504
+ | { kind: 'abandoned' };
505
+
506
+ const ERC20 = IERC20MetadataAbi__factory.createInterface();
507
+ const LENDING_POOL_MANAGER = ILendingPoolManagerAbi__factory.createInterface();
508
+
509
+ /**
510
+ * The batch path's two calls, encoded by the SDK so no consumer has to: the
511
+ * EXACT-amount approve, then the very `requestDepositWithKyc` the default
512
+ * `deposit` port sends — `swapData` `'0x'`, no value. Both read `amount` from
513
+ * the same variable, so the batch cannot approve one figure and deposit
514
+ * another.
515
+ */
516
+ function encodeDepositBatch(args: {
517
+ stableAsset: string;
518
+ spender: string;
519
+ input: DepositFlowInput;
520
+ depositData: string;
521
+ kyc: KycSignature;
522
+ }): BatchCall[] {
523
+ const { stableAsset, spender, input, depositData, kyc } = args;
524
+ return [
525
+ {
526
+ to: stableAsset,
527
+ data: ERC20.encodeFunctionData('approve', [spender, input.amount]),
528
+ },
529
+ {
530
+ to: spender,
531
+ data: LENDING_POOL_MANAGER.encodeFunctionData(
532
+ 'requestDepositWithKyc',
533
+ [
534
+ input.poolId,
535
+ input.trancheId,
536
+ input.amount,
537
+ '0x',
538
+ input.fixedTermConfigId,
539
+ depositData,
540
+ {
541
+ blockExpiration: kyc.blockExpiration,
542
+ signature: kyc.signature,
543
+ },
544
+ ],
545
+ ),
546
+ },
547
+ ];
548
+ }
549
+
337
550
  /** 1-based badge position, with `approve` dropped when it is out of scope. */
338
551
  function stepIndexOf(step: DepositStep, approvalRequired: boolean): number {
339
552
  const order: DepositStep[] = approvalRequired
@@ -354,18 +567,54 @@ function badgeFor(
354
567
  }
355
568
 
356
569
  /**
357
- * The request step has a third outcome. `UNPREDICTABLE_GAS_LIMIT` here is
358
- * almost always `transferFrom` reverting on a balance that cannot cover the
359
- * deposit, and it takes precedence: nothing was refused by the lender, so
360
- * inviting a retry would just reproduce it.
570
+ * The request step's extra outcomes, in the order the evidence supports.
571
+ *
572
+ * A revert is read for WHAT reverted before it is read as a shortfall.
573
+ * `requestDepositWithKyc` reverts for a whole family of declared reasons —
574
+ * `LendingPoolIsStopped`, `ClearingIsPending`, `UserNotKycd`, `UserBlocked`,
575
+ * `UserNotInAllowList`, `InvalidTranche`, `BlockExpired` — and every one of
576
+ * them used to arrive as `insufficient-balance`, which told a fully funded
577
+ * lender to top up a wallet that was never short while their pool was simply
578
+ * mid-clearing. So: a decoded protocol error is `reverted` and names itself.
579
+ *
580
+ * Only a token balance or allowance failure — or revert data nothing can
581
+ * decode, on the `UNPREDICTABLE_GAS_LIMIT` ethers raises for a failed gas
582
+ * estimate — is still `insufficient-balance`, which is the case that reason
583
+ * was named for and the one where topping up is genuinely the answer.
361
584
  */
362
585
  function classifyRequest(err: unknown): DepositFailure {
363
- if (isUnpredictableGas(err)) {
586
+ const revert = decodeRevert(err);
587
+ if (revert?.family === 'protocol') {
588
+ return {
589
+ step: 'request',
590
+ reason: 'reverted',
591
+ revertError: revert.name,
592
+ error: err,
593
+ };
594
+ }
595
+ if (revert !== null || isUnpredictableGas(err)) {
364
596
  return { step: 'request', reason: 'insufficient-balance', error: err };
365
597
  }
366
598
  return classifyWalletFailure('request', err);
367
599
  }
368
600
 
601
+ /**
602
+ * A `depositBatch` rejection. A submitter that KNOWS the batch reverts says so
603
+ * with `BatchRevertedError`; anything else is read exactly as a direct
604
+ * deposit's throw is, so a non-wallet error can never become `cancelled`.
605
+ */
606
+ function classifyBatch(err: unknown): DepositFailure {
607
+ if (err instanceof BatchRevertedError) {
608
+ return {
609
+ step: 'request',
610
+ reason: 'reverted',
611
+ revertError: err.revertError,
612
+ error: err,
613
+ };
614
+ }
615
+ return classifyRequest(err);
616
+ }
617
+
369
618
  // ---------------------------------------------------------------------------
370
619
  // The flow
371
620
  // ---------------------------------------------------------------------------
@@ -373,6 +622,7 @@ function classifyRequest(err: unknown): DepositFailure {
373
622
  export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
374
623
  private readonly _ttlMs: number;
375
624
  private readonly _defaultSpender: string | undefined;
625
+ private readonly _stableAsset: string | undefined;
376
626
  private readonly _now: () => number;
377
627
 
378
628
  /**
@@ -395,6 +645,15 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
395
645
  */
396
646
  private _acceptingFor: number | null = null;
397
647
 
648
+ /**
649
+ * The batch path's authorisation handshake. Same shape and lifetime as
650
+ * `_accept`: the run parks on it, `authoriseTransfer`, `declineTransfer`
651
+ * and `reset` settle it, and it is cleared the moment it settles.
652
+ */
653
+ private _authorise: {
654
+ settle: (outcome: AuthoriseOutcome) => void;
655
+ } | null = null;
656
+
398
657
  constructor(
399
658
  private readonly _ports: DepositPorts,
400
659
  opts?: DepositFlowOptions,
@@ -402,6 +661,7 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
402
661
  super(INITIAL);
403
662
  this._ttlMs = opts?.contractTtlMs ?? CONTRACT_TTL_MS;
404
663
  this._defaultSpender = opts?.spender;
664
+ this._stableAsset = opts?.stableAsset;
405
665
  // Called through the ports object, never captured off it: a consumer
406
666
  // whose clock is a method on its own object keeps its `this`.
407
667
  this._now = (): number => _ports.now?.() ?? Date.now();
@@ -428,6 +688,18 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
428
688
  const signature = await this._ports.signMessage(
429
689
  contract.contractMessage,
430
690
  );
691
+ // Checked HERE, not at the encoder: this is the signature the
692
+ // agreements service verifies the deposit against, and a wallet
693
+ // that returned something other than 65 bytes has not signed
694
+ // anything. Failing the confirm step names the step that produced
695
+ // it; letting it through would put an unverifiable blob on chain.
696
+ if (!isPersonalSignature(signature)) {
697
+ bridge.settle({
698
+ kind: 'failed',
699
+ error: new Error(INVALID_ACCEPTANCE_SIGNATURE_MESSAGE),
700
+ });
701
+ return;
702
+ }
431
703
  bridge.settle({ kind: 'accepted', signature });
432
704
  } catch (err) {
433
705
  // A WALLET error, and the only one this flow classifies as a
@@ -455,6 +727,33 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
455
727
  bridge.settle({ kind: 'declined' });
456
728
  }
457
729
 
730
+ /**
731
+ * Batch path: authorise the single transaction that approves and deposits,
732
+ * and resume the parked run. A no-op when nothing is parked on
733
+ * `awaiting-authorise`, so a double tap cannot submit twice.
734
+ *
735
+ * The batch has no wallet prompt per call, so this is the lender's
736
+ * explicit consent to the transfer — call it only from their action.
737
+ */
738
+ authoriseTransfer(): void {
739
+ const bridge = this._authorise;
740
+ if (!bridge) return;
741
+ this._authorise = null;
742
+ bridge.settle({ kind: 'authorised' });
743
+ }
744
+
745
+ /**
746
+ * Batch path: refuse the transfer. The run ends on `error` with `{ step:
747
+ * 'request', reason: 'cancelled' }` and nothing is submitted. A no-op when
748
+ * nothing is parked on `awaiting-authorise`.
749
+ */
750
+ declineTransfer(): void {
751
+ const bridge = this._authorise;
752
+ if (!bridge) return;
753
+ this._authorise = null;
754
+ bridge.settle({ kind: 'declined' });
755
+ }
756
+
458
757
  /** True only while THIS generation is waiting on the acceptance signature. */
459
758
  private _isAccepting(): boolean {
460
759
  return (
@@ -469,6 +768,9 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
469
768
  this._accept = null;
470
769
  this._acceptingFor = null;
471
770
  bridge?.settle({ kind: 'abandoned' });
771
+ const authorise = this._authorise;
772
+ this._authorise = null;
773
+ authorise?.settle({ kind: 'abandoned' });
472
774
  }
473
775
 
474
776
  // -----------------------------------------------------------------------
@@ -479,6 +781,7 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
479
781
  ): Promise<void> {
480
782
  this._accept = null;
481
783
  this._acceptingFor = null;
784
+ this._authorise = null;
482
785
  const ports = this._ports;
483
786
  const owner = input.userAddress.toLowerCase();
484
787
 
@@ -501,22 +804,35 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
501
804
  // live, never cached: an exact-amount approval is fully consumed by
502
805
  // the deposit it paid for, so a stale allowance is exactly the value
503
806
  // that would wrongly skip the approve and revert the deposit.
504
- let approvalRequired = true;
807
+ let allowanceShort = true;
505
808
  try {
506
809
  const allowance = await ports.readAllowance(owner, spender);
507
- approvalRequired = allowance.lt(input.amount);
810
+ allowanceShort = allowance.lt(input.amount);
508
811
  } catch {
509
812
  // A read failure is not a reason to skip an approval. Assume one is
510
813
  // needed: the cost is a redundant approve, the alternative is a
511
814
  // reverted deposit.
512
- approvalRequired = true;
815
+ allowanceShort = true;
513
816
  }
514
817
  if (!this._store.isCurrent(token)) return;
515
818
 
819
+ // The path is chosen HERE, with the allowance, so the badge is right
820
+ // from the first state the lender sees. Batch only when there is an
821
+ // approval to fold in, a port to submit it, and a known token to
822
+ // approve on — never a guessed one. A batched run never enters the
823
+ // `approve` step, so every badge counts it as a three-step run.
824
+ const stableAsset = this._stableAsset;
825
+ const batched =
826
+ allowanceShort &&
827
+ typeof ports.depositBatch === 'function' &&
828
+ !!stableAsset;
829
+ const approvalRequired = allowanceShort && !batched;
830
+
516
831
  // 2. Generate — sign the auth message.
517
832
  this._store.patch(
518
833
  {
519
834
  approvalRequired,
835
+ batched,
520
836
  phase: 'generating-sign',
521
837
  ...badgeFor('generate', approvalRequired),
522
838
  },
@@ -536,6 +852,18 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
536
852
  );
537
853
  return;
538
854
  }
855
+ // The same EIP-191 shape as the acceptance, and the same reason to
856
+ // check it: kasu-backend verifies this one to decide whether to issue
857
+ // an agreement at all, so a malformed signature is a 4xx that says
858
+ // nothing about which step produced it. Named here instead.
859
+ if (!isPersonalSignature(signature)) {
860
+ this._fail(token, approvalRequired, {
861
+ step: 'generate',
862
+ reason: 'failed',
863
+ error: new Error(INVALID_AUTH_SIGNATURE_MESSAGE),
864
+ });
865
+ return;
866
+ }
539
867
  if (!this._store.isCurrent(token)) return;
540
868
 
541
869
  // 3. Generate — POST the request.
@@ -620,12 +948,41 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
620
948
  return;
621
949
  }
622
950
 
623
- const depositData = encodeDepositData({
624
- signature: outcome.signature,
625
- timestamp: contract.timestamp,
626
- contractVersion: contract.contractVersion,
627
- contractType: asContractType(contract.contractType),
628
- });
951
+ // The acceptance signature is already known to be 65 bytes, but the
952
+ // encoder also reads `timestamp`, `contractVersion` and
953
+ // `contractType` off a response the SDK did not produce. `start()`
954
+ // promises it does not reject, so anything that throws in here becomes
955
+ // a failure the consumer can render rather than an unhandled rejection
956
+ // in an application that was told it never had to catch one.
957
+ let depositData: string;
958
+ try {
959
+ depositData = encodeDepositData({
960
+ signature: outcome.signature,
961
+ timestamp: contract.timestamp,
962
+ contractVersion: contract.contractVersion,
963
+ contractType: asContractType(contract.contractType),
964
+ });
965
+ } catch (err) {
966
+ this._fail(token, approvalRequired, {
967
+ step: 'request',
968
+ reason: 'failed',
969
+ error: err,
970
+ });
971
+ return;
972
+ }
973
+
974
+ if (batched && stableAsset) {
975
+ await this._runBatch({
976
+ token,
977
+ input,
978
+ owner,
979
+ spender,
980
+ stableAsset,
981
+ depositData,
982
+ contract,
983
+ });
984
+ return;
985
+ }
629
986
 
630
987
  // 6. Approve — the EXACT amount, never `MaxUint256`. House rule: an
631
988
  // unlimited allowance outlives the deposit it was granted for, and a
@@ -709,6 +1066,136 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
709
1066
  );
710
1067
  }
711
1068
 
1069
+ /**
1070
+ * The batch path, from the accepted agreement to the receipt.
1071
+ *
1072
+ * 1. PARK on `awaiting-authorise`. A batch puts no wallet prompt in front
1073
+ * of each call, so the flow waits for an explicit authorisation before
1074
+ * any money moves. `declineTransfer()` ends it as `cancelled`.
1075
+ * 2. Re-check the TTL: the lender may have idled on the authorisation as
1076
+ * long as they did on the agreement.
1077
+ * 3. Only now fetch the KYC signature — it has a block expiration of its
1078
+ * own, and an authorisation left open must not spend it.
1079
+ * 4. Encode both calls and hand them to `depositBatch`; then the receipt.
1080
+ */
1081
+ private async _runBatch(args: {
1082
+ token: number;
1083
+ input: DepositFlowInput;
1084
+ owner: string;
1085
+ spender: string;
1086
+ stableAsset: string;
1087
+ depositData: string;
1088
+ contract: GenerateContractResponse;
1089
+ }): Promise<void> {
1090
+ const { token, input, owner, spender, stableAsset, depositData } =
1091
+ args;
1092
+ const ports = this._ports;
1093
+ // A batched run is a three-step run on every badge.
1094
+ const approvalRequired = false;
1095
+
1096
+ // Every settler clears the handshake before settling it, so there is
1097
+ // nothing to clear here — and clearing it anyway could drop the
1098
+ // handshake of a NEWER run that `reset(); start()` already parked.
1099
+ const outcome = await new Promise<AuthoriseOutcome>((resolve) => {
1100
+ this._authorise = { settle: resolve };
1101
+ this._store.patch(
1102
+ {
1103
+ phase: 'awaiting-authorise',
1104
+ ...badgeFor('request', approvalRequired),
1105
+ },
1106
+ token,
1107
+ );
1108
+ });
1109
+ if (!this._store.isCurrent(token)) return;
1110
+ if (outcome.kind === 'declined') {
1111
+ this._fail(token, approvalRequired, {
1112
+ step: 'request',
1113
+ reason: 'cancelled',
1114
+ });
1115
+ return;
1116
+ }
1117
+ if (outcome.kind !== 'authorised') return;
1118
+
1119
+ if (this._now() > args.contract.timestamp + this._ttlMs) {
1120
+ this._fail(token, approvalRequired, {
1121
+ step: 'request',
1122
+ reason: 'contract-expired',
1123
+ });
1124
+ return;
1125
+ }
1126
+
1127
+ this._store.patch(
1128
+ { phase: 'request-sign', ...badgeFor('request', approvalRequired) },
1129
+ token,
1130
+ );
1131
+
1132
+ // The consumer's backend, so `failed` — never `cancelled` — exactly as
1133
+ // on the two-transaction path.
1134
+ let kyc: KycSignature;
1135
+ try {
1136
+ const kycParams = await ports.buildKycParams(
1137
+ owner as `0x${string}`,
1138
+ );
1139
+ kyc = await ports.getKycSignature(kycParams);
1140
+ } catch (err) {
1141
+ this._fail(token, approvalRequired, {
1142
+ step: 'request',
1143
+ reason: 'failed',
1144
+ error: err,
1145
+ });
1146
+ return;
1147
+ }
1148
+ if (!this._store.isCurrent(token)) return;
1149
+
1150
+ // Encoded from values a backend returned (the KYC signature), so a
1151
+ // shape the coder refuses is a failure the consumer can render, not a
1152
+ // rejection out of a `start()` that promises it has none.
1153
+ let calls: BatchCall[];
1154
+ try {
1155
+ calls = encodeDepositBatch({
1156
+ stableAsset,
1157
+ spender,
1158
+ input,
1159
+ depositData,
1160
+ kyc,
1161
+ });
1162
+ } catch (err) {
1163
+ this._fail(token, approvalRequired, {
1164
+ step: 'request',
1165
+ reason: 'failed',
1166
+ error: err,
1167
+ });
1168
+ return;
1169
+ }
1170
+
1171
+ try {
1172
+ // Called through the ports object, so a port that is a method
1173
+ // keeps its `this`. Its presence decided the path; a consumer
1174
+ // that removed it since gets a `failed`, not a crash.
1175
+ if (typeof ports.depositBatch !== 'function') {
1176
+ throw new Error('DepositFlow: the depositBatch port is gone');
1177
+ }
1178
+ const tx = await ports.depositBatch(calls);
1179
+ this._store.patch(
1180
+ {
1181
+ phase: 'request-confirm',
1182
+ ...badgeFor('request', approvalRequired),
1183
+ },
1184
+ token,
1185
+ );
1186
+ await tx.wait();
1187
+ } catch (err) {
1188
+ this._fail(token, approvalRequired, classifyBatch(err));
1189
+ return;
1190
+ }
1191
+ if (!this._store.isCurrent(token)) return;
1192
+
1193
+ this._store.patch(
1194
+ { phase: 'success', ...badgeFor('request', approvalRequired) },
1195
+ token,
1196
+ );
1197
+ }
1198
+
712
1199
  private _fail(
713
1200
  token: number,
714
1201
  approvalRequired: boolean,