@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.
- package/README.md +88 -6
- package/dist/bundle.cjs.js +541 -21
- package/dist/bundle.esm.js +537 -22
- package/dist/domain/index.d.ts +2 -0
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/domain/revert-errors.d.ts +59 -0
- package/dist/domain/revert-errors.js +190 -0
- package/dist/domain/revert-errors.js.map +1 -0
- package/dist/facade/flows.d.ts +17 -1
- package/dist/facade/flows.js +15 -2
- package/dist/facade/flows.js.map +1 -1
- package/dist/facade/kasu.js +2 -1
- package/dist/facade/kasu.js.map +1 -1
- package/dist/flows/deposit-flow.d.ts +170 -8
- package/dist/flows/deposit-flow.js +333 -15
- package/dist/flows/deposit-flow.js.map +1 -1
- package/dist/flows/index.d.ts +2 -2
- package/dist/flows/index.js +1 -1
- package/dist/flows/index.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/index.ts +3 -0
- package/src/domain/revert-errors.ts +248 -0
- package/src/facade/flows.ts +12 -0
- package/src/facade/kasu.ts +1 -0
- package/src/flows/deposit-flow.ts +509 -22
- package/src/flows/index.ts +4 -0
|
@@ -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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
416
|
+
/**
|
|
417
|
+
* `4`, or `3` when the allowance already covers the deposit or the run
|
|
418
|
+
* is batched.
|
|
419
|
+
*/
|
|
290
420
|
stepTotal: number;
|
|
291
|
-
/**
|
|
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
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
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
|
-
|
|
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
|
|
807
|
+
let allowanceShort = true;
|
|
505
808
|
try {
|
|
506
809
|
const allowance = await ports.readAllowance(owner, spender);
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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,
|