@kasufinance/kasu-sdk 2.7.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { BigNumber } from 'ethers';
1
+ import { BigNumber, utils } from 'ethers';
2
2
 
3
3
  import {
4
4
  asContractType,
@@ -7,6 +7,7 @@ import {
7
7
  encodeDepositData,
8
8
  GenerateContractResponse,
9
9
  } from '../domain/loan-contract';
10
+ import { decodeRevert } from '../domain/revert-errors';
10
11
  import {
11
12
  classifyWalletFailure,
12
13
  isUnpredictableGas,
@@ -64,10 +65,21 @@ import { WaitableTransaction } from './observable';
64
65
  * their mind, and telling them something broke would be a lie. Only a WALLET
65
66
  * call is ever classified that way: the HTTP ports are always `'failed'`,
66
67
  * 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.
68
+ * lender's wallet. A revert on the request step is read for WHAT reverted: a
69
+ * protocol error the ABI declares is `'reverted'` with the name, and only a
70
+ * token balance or allowance failure — or a revert nothing can decode — stays
71
+ * `'insufficient-balance'`. Everything else is `'failed'` and carries the
72
+ * original error for the consumer's crash reporter.
73
+ *
74
+ * ## Both signatures are checked before they are used
75
+ *
76
+ * A wallet is not obliged to hand back 65 bytes, and `encodeDepositData` will
77
+ * not catch one that does not: `defaultAbiCoder` encodes any even-length hex
78
+ * as `bytes`, `0x` included, so a malformed acceptance signature becomes a
79
+ * `depositData` blob that is broadcast, mined, and then can never be verified
80
+ * by the agreements service. Both personal signs are therefore length-checked
81
+ * where they are taken, and a bad one fails the step it was taken on rather
82
+ * than the one it would eventually have broken.
71
83
  *
72
84
  * ```ts
73
85
  * const flow = new DepositFlow(ports);
@@ -117,6 +129,23 @@ export type DepositFailure =
117
129
  | { step: DepositStep; reason: 'cancelled' }
118
130
  | { step: DepositStep; reason: 'failed'; error: unknown }
119
131
  | { step: 'request'; reason: 'insufficient-balance'; error: unknown }
132
+ | {
133
+ step: 'request';
134
+ reason: 'reverted';
135
+ /**
136
+ * The custom error the contract reverted with, exactly as the ABI
137
+ * declares it — `'ClearingIsPending'`, `'LendingPoolIsStopped'`,
138
+ * `'UserNotKycd'`, and the rest of
139
+ * `ILendingPoolManagerAbi` / `IKasuAllowListAbi`.
140
+ *
141
+ * Render `reverted` with generic copy and special-case only the
142
+ * names you have words for: a contract upgrade can add an error, and
143
+ * a consumer that assumed the set was closed would have nothing to
144
+ * show for the new one.
145
+ */
146
+ revertError: string;
147
+ error: unknown;
148
+ }
120
149
  | { step: 'request'; reason: 'contract-expired' };
121
150
 
122
151
  // ---------------------------------------------------------------------------
@@ -303,6 +332,27 @@ export const CONTRACT_TTL_MS = 5 * 60 * 1000;
303
332
  export const NO_SPENDER_MESSAGE =
304
333
  'DepositFlow: no ERC-20 spender; build the flow with kasu.flows.deposit() or pass `spender` on the input';
305
334
 
335
+ /** What a malformed auth signature fails the generate step with. */
336
+ export const INVALID_AUTH_SIGNATURE_MESSAGE =
337
+ 'DepositFlow: the wallet returned a malformed authentication signature; expected 65 bytes of 0x-prefixed hex';
338
+
339
+ /** What a malformed acceptance signature fails the confirm step with. */
340
+ export const INVALID_ACCEPTANCE_SIGNATURE_MESSAGE =
341
+ 'DepositFlow: the wallet returned a malformed acceptance signature; expected 65 bytes of 0x-prefixed hex';
342
+
343
+ /**
344
+ * Is this an EIP-191 personal sign, in the only shape the protocol accepts?
345
+ *
346
+ * 65 bytes — `r`, `s`, `v` — 0x-prefixed. Nothing downstream checks it: the
347
+ * ABI coder takes any even-length hex as `bytes`, so `'0x'` and a 64-byte
348
+ * string both encode happily into a `depositData` the agreements service then
349
+ * cannot verify against anything. The check has to happen where the signature
350
+ * is taken, which is here.
351
+ */
352
+ function isPersonalSignature(signature: unknown): signature is string {
353
+ return utils.isHexString(signature, 65);
354
+ }
355
+
306
356
  const INITIAL: DepositState = {
307
357
  phase: 'idle',
308
358
  step: null,
@@ -354,13 +404,32 @@ function badgeFor(
354
404
  }
355
405
 
356
406
  /**
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.
407
+ * The request step's extra outcomes, in the order the evidence supports.
408
+ *
409
+ * A revert is read for WHAT reverted before it is read as a shortfall.
410
+ * `requestDepositWithKyc` reverts for a whole family of declared reasons —
411
+ * `LendingPoolIsStopped`, `ClearingIsPending`, `UserNotKycd`, `UserBlocked`,
412
+ * `UserNotInAllowList`, `InvalidTranche`, `BlockExpired` — and every one of
413
+ * them used to arrive as `insufficient-balance`, which told a fully funded
414
+ * lender to top up a wallet that was never short while their pool was simply
415
+ * mid-clearing. So: a decoded protocol error is `reverted` and names itself.
416
+ *
417
+ * Only a token balance or allowance failure — or revert data nothing can
418
+ * decode, on the `UNPREDICTABLE_GAS_LIMIT` ethers raises for a failed gas
419
+ * estimate — is still `insufficient-balance`, which is the case that reason
420
+ * was named for and the one where topping up is genuinely the answer.
361
421
  */
362
422
  function classifyRequest(err: unknown): DepositFailure {
363
- if (isUnpredictableGas(err)) {
423
+ const revert = decodeRevert(err);
424
+ if (revert?.family === 'protocol') {
425
+ return {
426
+ step: 'request',
427
+ reason: 'reverted',
428
+ revertError: revert.name,
429
+ error: err,
430
+ };
431
+ }
432
+ if (revert !== null || isUnpredictableGas(err)) {
364
433
  return { step: 'request', reason: 'insufficient-balance', error: err };
365
434
  }
366
435
  return classifyWalletFailure('request', err);
@@ -428,6 +497,18 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
428
497
  const signature = await this._ports.signMessage(
429
498
  contract.contractMessage,
430
499
  );
500
+ // Checked HERE, not at the encoder: this is the signature the
501
+ // agreements service verifies the deposit against, and a wallet
502
+ // that returned something other than 65 bytes has not signed
503
+ // anything. Failing the confirm step names the step that produced
504
+ // it; letting it through would put an unverifiable blob on chain.
505
+ if (!isPersonalSignature(signature)) {
506
+ bridge.settle({
507
+ kind: 'failed',
508
+ error: new Error(INVALID_ACCEPTANCE_SIGNATURE_MESSAGE),
509
+ });
510
+ return;
511
+ }
431
512
  bridge.settle({ kind: 'accepted', signature });
432
513
  } catch (err) {
433
514
  // A WALLET error, and the only one this flow classifies as a
@@ -536,6 +617,18 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
536
617
  );
537
618
  return;
538
619
  }
620
+ // The same EIP-191 shape as the acceptance, and the same reason to
621
+ // check it: kasu-backend verifies this one to decide whether to issue
622
+ // an agreement at all, so a malformed signature is a 4xx that says
623
+ // nothing about which step produced it. Named here instead.
624
+ if (!isPersonalSignature(signature)) {
625
+ this._fail(token, approvalRequired, {
626
+ step: 'generate',
627
+ reason: 'failed',
628
+ error: new Error(INVALID_AUTH_SIGNATURE_MESSAGE),
629
+ });
630
+ return;
631
+ }
539
632
  if (!this._store.isCurrent(token)) return;
540
633
 
541
634
  // 3. Generate — POST the request.
@@ -620,12 +713,28 @@ export class DepositFlow extends Flow<DepositState, DepositFlowInput> {
620
713
  return;
621
714
  }
622
715
 
623
- const depositData = encodeDepositData({
624
- signature: outcome.signature,
625
- timestamp: contract.timestamp,
626
- contractVersion: contract.contractVersion,
627
- contractType: asContractType(contract.contractType),
628
- });
716
+ // The acceptance signature is already known to be 65 bytes, but the
717
+ // encoder also reads `timestamp`, `contractVersion` and
718
+ // `contractType` off a response the SDK did not produce. `start()`
719
+ // promises it does not reject, so anything that throws in here becomes
720
+ // a failure the consumer can render rather than an unhandled rejection
721
+ // in an application that was told it never had to catch one.
722
+ let depositData: string;
723
+ try {
724
+ depositData = encodeDepositData({
725
+ signature: outcome.signature,
726
+ timestamp: contract.timestamp,
727
+ contractVersion: contract.contractVersion,
728
+ contractType: asContractType(contract.contractType),
729
+ });
730
+ } catch (err) {
731
+ this._fail(token, approvalRequired, {
732
+ step: 'request',
733
+ reason: 'failed',
734
+ error: err,
735
+ });
736
+ return;
737
+ }
629
738
 
630
739
  // 6. Approve — the EXACT amount, never `MaxUint256`. House rule: an
631
740
  // unlimited allowance outlives the deposit it was granted for, and a
@@ -17,6 +17,8 @@ export type { WaitableTransaction } from './observable';
17
17
  export {
18
18
  CONTRACT_TTL_MS,
19
19
  DepositFlow,
20
+ INVALID_ACCEPTANCE_SIGNATURE_MESSAGE,
21
+ INVALID_AUTH_SIGNATURE_MESSAGE,
20
22
  NO_SPENDER_MESSAGE,
21
23
  } from './deposit-flow';
22
24
  export type {