@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.
package/README.md CHANGED
@@ -147,7 +147,8 @@ application formats them in its own design system and language.
147
147
  | Requests | `deriveRequestState`, `submissionEvents`, `countSubmissions`, `firstSubmissionTimestamp`, `isCycleClosed` |
148
148
  | Settlement | `computeSettlementWindow`, `nextCycleBoundary`, `deriveCycleDates`, `CLEARING_WINDOW_SECONDS` |
149
149
  | Loan contract | `encodeDepositData`, `buildContractVersionType`, `buildLoanAgreementSignMessage`, `buildLegacyContractRequestMessage`, `buildFullNameRequestMessage`, `formatSignTimestampUtc`, `parseFormattedMessage`, `asContractType` |
150
- | Wallet errors | `isUserRejected`, `isUnpredictableGas` |
150
+ | Wallet errors | `isUserRejected`, `isUnpredictableGas`, `classifyWalletFailure` |
151
+ | Revert errors | `decodeRevert`, `extractRevertData` — the bytes a reverted call came back with, read as the custom error the contract named |
151
152
  | AU minimum | `auMinimumRemaining`, `isAustralianKyc`, `auThresholdFor`, `isAuMinimumExempt`, `parseMinorUnits`, `AU_ALPHA3`, `AU_MIN_CUMULATIVE_BY_STABLE` |
152
153
 
153
154
  Three rules worth knowing:
@@ -239,14 +240,51 @@ type DepositFailure =
239
240
  | { step: DepositStep; reason: 'cancelled' } // isUserRejected
240
241
  | { step: DepositStep; reason: 'failed'; error: unknown }
241
242
  | { step: 'request'; reason: 'insufficient-balance'; error: unknown }
243
+ | {
244
+ step: 'request';
245
+ reason: 'reverted';
246
+ revertError: string; // e.g. 'ClearingIsPending'
247
+ error: unknown;
248
+ }
242
249
  | { step: 'request'; reason: 'contract-expired' }; // the 5-minute TTL
243
250
  ```
244
251
 
245
252
  A wallet rejection is `cancelled`, not a failure: the lender changed their
246
- mind, and telling them something broke would be a lie. A reverted gas estimate
247
- on the request step is `insufficient-balance` — nothing was refused, so a retry
248
- would only reproduce it. Everything else is `failed` and carries the original
249
- error for your crash reporter.
253
+ mind, and telling them something broke would be a lie. Everything else is
254
+ `failed` and carries the original error for your crash reporter.
255
+
256
+ A revert on the request step is read for WHAT reverted. `requestDepositWithKyc`
257
+ reverts for a family of reasons the ABI declares — `LendingPoolIsStopped`,
258
+ `ClearingIsPending`, `InvalidTranche`, `UserNotKycd`, `UserBlocked`,
259
+ `UserNotInAllowList`, `BlockExpired` — and none of them is a shortfall. Those
260
+ come back as `reverted`, with `revertError` naming the contract error. Only a
261
+ token balance or allowance failure, or revert data nothing can decode, is
262
+ `insufficient-balance`.
263
+
264
+ **Render `reverted` with generic copy and special-case only the names you have
265
+ words for.** A contract upgrade can add an error, so treat the set as open: a
266
+ consumer that assumed it was closed has nothing to show for a new one.
267
+ `decodeRevert(err)` is exported if you want the same reading elsewhere.
268
+
269
+ ```ts
270
+ if (failure.reason === 'reverted') {
271
+ switch (failure.revertError) {
272
+ case 'ClearingIsPending':
273
+ return t('deposit.error.clearing');
274
+ case 'UserNotKycd':
275
+ return t('deposit.error.kyc');
276
+ default:
277
+ return t('deposit.error.rejectedOnChain'); // generic
278
+ }
279
+ }
280
+ ```
281
+
282
+ **Both personal signs are length-checked where they are taken.** A wallet is
283
+ not obliged to return 65 bytes and the ABI coder will not catch one that does
284
+ not — `0x` encodes into `depositData` as happily as a real signature does, and
285
+ the agreements service could then never verify the deposit. A malformed auth
286
+ signature fails the `generate` step and a malformed acceptance signature fails
287
+ `confirm`, both as `failed`, before anything is broadcast.
250
288
 
251
289
  Three guarantees worth knowing:
252
290
 
@@ -20837,12 +20837,12 @@ function toURLEncodedForm(data, options) {
20837
20837
  });
20838
20838
  }
20839
20839
 
20840
- const MAX_DEPTH = DEFAULT_FORM_DATA_MAX_DEPTH;
20840
+ const MAX_DEPTH$1 = DEFAULT_FORM_DATA_MAX_DEPTH;
20841
20841
 
20842
20842
  function throwIfDepthExceeded(index) {
20843
- if (index > MAX_DEPTH) {
20843
+ if (index > MAX_DEPTH$1) {
20844
20844
  throw new AxiosError$1(
20845
- 'FormData field is too deeply nested (' + index + ' levels). Max depth: ' + MAX_DEPTH,
20845
+ 'FormData field is too deeply nested (' + index + ' levels). Max depth: ' + MAX_DEPTH$1,
20846
20846
  AxiosError$1.ERR_FORM_DATA_DEPTH_EXCEEDED
20847
20847
  );
20848
20848
  }
@@ -24806,6 +24806,193 @@ function buildFullNameRequestMessage(address, timestampMs) {
24806
24806
  return `I request my full name for ${address.toLowerCase()} at ${timestampMs}.`;
24807
24807
  }
24808
24808
 
24809
+ // ---------------------------------------------------------------------------
24810
+ // The selector tables
24811
+ // ---------------------------------------------------------------------------
24812
+ /**
24813
+ * OpenZeppelin v5's ERC-20 errors. They are not in either Kasu ABI because no
24814
+ * Kasu contract declares them — they arrive from the TOKEN, through the
24815
+ * manager's `transferFrom`, and they are the modern spelling of the string
24816
+ * reverts below.
24817
+ */
24818
+ const ERC20_ERROR_ABI = [
24819
+ 'error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)',
24820
+ 'error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed)',
24821
+ ];
24822
+ /**
24823
+ * The protocol errors that mean the ERC-20 transfer failed rather than the
24824
+ * protocol refusing.
24825
+ *
24826
+ * `SafeERC20FailedOperation` is OpenZeppelin's wrapper around a token call
24827
+ * that returned false or reverted, which on this path IS the `transferFrom`;
24828
+ * `AddressInsufficientBalance` is its native-value sibling. Both belong with
24829
+ * the balance case, not with the pool conditions — a lender who sees one of
24830
+ * them really does need to fund or re-approve.
24831
+ */
24832
+ const ERC20_FAMILY_NAMES = new Set([
24833
+ 'AddressInsufficientBalance',
24834
+ 'ERC20InsufficientAllowance',
24835
+ 'ERC20InsufficientBalance',
24836
+ 'SafeERC20FailedOperation',
24837
+ ]);
24838
+ /** Solidity's `Error(string)`, the selector every `require` reason arrives under. */
24839
+ const STRING_REVERT_SELECTOR = '0x08c379a0';
24840
+ /**
24841
+ * The `require` reasons that are a balance or an allowance, across the token
24842
+ * implementations in circulation: OpenZeppelin v4's sentences, and the
24843
+ * shorthand the minimal implementations use.
24844
+ */
24845
+ const ERC20_REASON_PATTERNS = [
24846
+ /exceeds\s+balance/,
24847
+ /exceeds\s+allowance/,
24848
+ /insufficient\s+balance/,
24849
+ /insufficient\s+allowance/,
24850
+ /transfer_from_failed/,
24851
+ /transferfrom\s+failed/,
24852
+ /low-level\s+call\s+failed/,
24853
+ ];
24854
+ /**
24855
+ * selector → error name, built once on first use.
24856
+ *
24857
+ * Lazily, because a consumer that never sees a revert should not pay for three
24858
+ * `Interface` constructions at import. The two Kasu tables are the GENERATED
24859
+ * typechain ABIs, so this cannot drift from the contracts: regenerate the ABIs
24860
+ * and the new errors are recognised with no edit here.
24861
+ */
24862
+ let selectorTable = null;
24863
+ function selectors() {
24864
+ if (selectorTable)
24865
+ return selectorTable;
24866
+ const table = new Map();
24867
+ const interfaces = [
24868
+ // The manager the deposit is called against, and the allow list it
24869
+ // delegates the KYC and block-window checks to.
24870
+ ILendingPoolManagerAbi__factory.createInterface(),
24871
+ IKasuAllowListAbi__factory.createInterface(),
24872
+ new ethers.ethers.utils.Interface(ERC20_ERROR_ABI),
24873
+ ];
24874
+ for (const iface of interfaces) {
24875
+ for (const fragment of Object.values(iface.errors)) {
24876
+ // First writer wins: the same name declared by both ABIs is the
24877
+ // same selector, so the duplicate would be a no-op anyway.
24878
+ const sighash = iface.getSighash(fragment);
24879
+ if (!table.has(sighash))
24880
+ table.set(sighash, fragment.name);
24881
+ }
24882
+ }
24883
+ selectorTable = table;
24884
+ return table;
24885
+ }
24886
+ // ---------------------------------------------------------------------------
24887
+ // Getting the bytes out of the envelope
24888
+ // ---------------------------------------------------------------------------
24889
+ /**
24890
+ * How deep to look for the revert data. Providers nest it differently —
24891
+ * ethers' own error wraps the provider's, which wraps the RPC body — and a
24892
+ * bound is what keeps a self-referencing error object from being walked
24893
+ * forever.
24894
+ */
24895
+ const MAX_DEPTH = 6;
24896
+ /** The keys ethers and the providers under it put a wrapped error on. */
24897
+ const NESTED_KEYS = [
24898
+ 'data',
24899
+ 'error',
24900
+ 'originalError',
24901
+ 'info',
24902
+ 'cause',
24903
+ 'body',
24904
+ ];
24905
+ /** At least a 4-byte selector, and a whole number of bytes. */
24906
+ function isRevertData(value) {
24907
+ return (value.length >= 10 &&
24908
+ value.length % 2 === 0 &&
24909
+ /^0x[0-9a-fA-F]+$/.test(value));
24910
+ }
24911
+ /**
24912
+ * Find the revert data on an error, wherever the provider left it.
24913
+ *
24914
+ * ethers v5 raises `UNPREDICTABLE_GAS_LIMIT` with the provider's own error
24915
+ * attached, and each provider nests the bytes one layer differently:
24916
+ * `error.data`, `error.error.data`, `error.data.originalError.data`, or only
24917
+ * inside the JSON `body` of a `SERVER_ERROR`. Rather than enumerate the
24918
+ * products, walk a fixed set of keys to a fixed depth and take the first value
24919
+ * that is shaped like revert data.
24920
+ */
24921
+ function extractRevertData(err) {
24922
+ return walk(err, 0);
24923
+ }
24924
+ function walk(node, depth) {
24925
+ if (node == null || depth > MAX_DEPTH)
24926
+ return null;
24927
+ if (typeof node === 'string') {
24928
+ if (isRevertData(node))
24929
+ return node;
24930
+ // A `SERVER_ERROR`'s body: the RPC's JSON reply, verbatim.
24931
+ if (!node.startsWith('{'))
24932
+ return null;
24933
+ try {
24934
+ return walk(JSON.parse(node), depth + 1);
24935
+ }
24936
+ catch (_a) {
24937
+ return null;
24938
+ }
24939
+ }
24940
+ if (typeof node !== 'object')
24941
+ return null;
24942
+ const record = node;
24943
+ for (const key of NESTED_KEYS) {
24944
+ const found = walk(record[key], depth + 1);
24945
+ if (found)
24946
+ return found;
24947
+ }
24948
+ return null;
24949
+ }
24950
+ // ---------------------------------------------------------------------------
24951
+ // Decoding
24952
+ // ---------------------------------------------------------------------------
24953
+ /**
24954
+ * Decode a failed call's revert into the error the contract named, or `null`
24955
+ * when there is nothing recognisable to name.
24956
+ *
24957
+ * `null` is the honest answer for an unknown selector, an unrelated `require`
24958
+ * string, or an error with no revert data at all — the caller keeps whatever
24959
+ * it did before rather than inventing a diagnosis from bytes it cannot read.
24960
+ */
24961
+ function decodeRevert(err) {
24962
+ const data = extractRevertData(err);
24963
+ if (!data)
24964
+ return null;
24965
+ const selector = data.slice(0, 10).toLowerCase();
24966
+ if (selector === STRING_REVERT_SELECTOR) {
24967
+ const reason = decodeStringRevert(data);
24968
+ if (reason === null)
24969
+ return null;
24970
+ return isErc20Reason(reason)
24971
+ ? { name: 'Error', family: 'erc20', reason }
24972
+ : null;
24973
+ }
24974
+ const name = selectors().get(selector);
24975
+ if (!name)
24976
+ return null;
24977
+ return {
24978
+ name,
24979
+ family: ERC20_FAMILY_NAMES.has(name) ? 'erc20' : 'protocol',
24980
+ };
24981
+ }
24982
+ function decodeStringRevert(data) {
24983
+ try {
24984
+ const [reason] = ethers.ethers.utils.defaultAbiCoder.decode(['string'], `0x${data.slice(10)}`);
24985
+ return reason;
24986
+ }
24987
+ catch (_a) {
24988
+ return null;
24989
+ }
24990
+ }
24991
+ function isErc20Reason(reason) {
24992
+ const lower = reason.toLowerCase();
24993
+ return ERC20_REASON_PATTERNS.some((pattern) => pattern.test(lower));
24994
+ }
24995
+
24809
24996
  /**
24810
24997
  * Wallet and RPC error predicates — pure functions over `unknown`.
24811
24998
  *
@@ -24965,6 +25152,22 @@ function classifyWalletFailure(step, err) {
24965
25152
  const CONTRACT_TTL_MS = 5 * 60 * 1000;
24966
25153
  /** What a run without a spender, from either source, fails with. */
24967
25154
  const NO_SPENDER_MESSAGE = 'DepositFlow: no ERC-20 spender; build the flow with kasu.flows.deposit() or pass `spender` on the input';
25155
+ /** What a malformed auth signature fails the generate step with. */
25156
+ const INVALID_AUTH_SIGNATURE_MESSAGE = 'DepositFlow: the wallet returned a malformed authentication signature; expected 65 bytes of 0x-prefixed hex';
25157
+ /** What a malformed acceptance signature fails the confirm step with. */
25158
+ const INVALID_ACCEPTANCE_SIGNATURE_MESSAGE = 'DepositFlow: the wallet returned a malformed acceptance signature; expected 65 bytes of 0x-prefixed hex';
25159
+ /**
25160
+ * Is this an EIP-191 personal sign, in the only shape the protocol accepts?
25161
+ *
25162
+ * 65 bytes — `r`, `s`, `v` — 0x-prefixed. Nothing downstream checks it: the
25163
+ * ABI coder takes any even-length hex as `bytes`, so `'0x'` and a 64-byte
25164
+ * string both encode happily into a `depositData` the agreements service then
25165
+ * cannot verify against anything. The check has to happen where the signature
25166
+ * is taken, which is here.
25167
+ */
25168
+ function isPersonalSignature(signature) {
25169
+ return ethers.utils.isHexString(signature, 65);
25170
+ }
24968
25171
  const INITIAL$1 = {
24969
25172
  phase: 'idle',
24970
25173
  step: null,
@@ -24989,13 +25192,32 @@ function badgeFor(step, approvalRequired) {
24989
25192
  };
24990
25193
  }
24991
25194
  /**
24992
- * The request step has a third outcome. `UNPREDICTABLE_GAS_LIMIT` here is
24993
- * almost always `transferFrom` reverting on a balance that cannot cover the
24994
- * deposit, and it takes precedence: nothing was refused by the lender, so
24995
- * inviting a retry would just reproduce it.
25195
+ * The request step's extra outcomes, in the order the evidence supports.
25196
+ *
25197
+ * A revert is read for WHAT reverted before it is read as a shortfall.
25198
+ * `requestDepositWithKyc` reverts for a whole family of declared reasons —
25199
+ * `LendingPoolIsStopped`, `ClearingIsPending`, `UserNotKycd`, `UserBlocked`,
25200
+ * `UserNotInAllowList`, `InvalidTranche`, `BlockExpired` — and every one of
25201
+ * them used to arrive as `insufficient-balance`, which told a fully funded
25202
+ * lender to top up a wallet that was never short while their pool was simply
25203
+ * mid-clearing. So: a decoded protocol error is `reverted` and names itself.
25204
+ *
25205
+ * Only a token balance or allowance failure — or revert data nothing can
25206
+ * decode, on the `UNPREDICTABLE_GAS_LIMIT` ethers raises for a failed gas
25207
+ * estimate — is still `insufficient-balance`, which is the case that reason
25208
+ * was named for and the one where topping up is genuinely the answer.
24996
25209
  */
24997
25210
  function classifyRequest(err) {
24998
- if (isUnpredictableGas(err)) {
25211
+ const revert = decodeRevert(err);
25212
+ if ((revert === null || revert === void 0 ? void 0 : revert.family) === 'protocol') {
25213
+ return {
25214
+ step: 'request',
25215
+ reason: 'reverted',
25216
+ revertError: revert.name,
25217
+ error: err,
25218
+ };
25219
+ }
25220
+ if (revert !== null || isUnpredictableGas(err)) {
24999
25221
  return { step: 'request', reason: 'insufficient-balance', error: err };
25000
25222
  }
25001
25223
  return classifyWalletFailure('request', err);
@@ -25047,6 +25269,18 @@ class DepositFlow extends Flow {
25047
25269
  this._store.patch(Object.assign({ phase: 'accepting-sign' }, badgeFor('confirm', this._store.state.approvalRequired)), token);
25048
25270
  try {
25049
25271
  const signature = yield this._ports.signMessage(contract.contractMessage);
25272
+ // Checked HERE, not at the encoder: this is the signature the
25273
+ // agreements service verifies the deposit against, and a wallet
25274
+ // that returned something other than 65 bytes has not signed
25275
+ // anything. Failing the confirm step names the step that produced
25276
+ // it; letting it through would put an unverifiable blob on chain.
25277
+ if (!isPersonalSignature(signature)) {
25278
+ bridge.settle({
25279
+ kind: 'failed',
25280
+ error: new Error(INVALID_ACCEPTANCE_SIGNATURE_MESSAGE),
25281
+ });
25282
+ return;
25283
+ }
25050
25284
  bridge.settle({ kind: 'accepted', signature });
25051
25285
  }
25052
25286
  catch (err) {
@@ -25140,6 +25374,18 @@ class DepositFlow extends Flow {
25140
25374
  this._fail(token, approvalRequired, classifyWalletFailure('generate', err));
25141
25375
  return;
25142
25376
  }
25377
+ // The same EIP-191 shape as the acceptance, and the same reason to
25378
+ // check it: kasu-backend verifies this one to decide whether to issue
25379
+ // an agreement at all, so a malformed signature is a 4xx that says
25380
+ // nothing about which step produced it. Named here instead.
25381
+ if (!isPersonalSignature(signature)) {
25382
+ this._fail(token, approvalRequired, {
25383
+ step: 'generate',
25384
+ reason: 'failed',
25385
+ error: new Error(INVALID_AUTH_SIGNATURE_MESSAGE),
25386
+ });
25387
+ return;
25388
+ }
25143
25389
  if (!this._store.isCurrent(token))
25144
25390
  return;
25145
25391
  // 3. Generate — POST the request.
@@ -25196,12 +25442,29 @@ class DepositFlow extends Flow {
25196
25442
  });
25197
25443
  return;
25198
25444
  }
25199
- const depositData = encodeDepositData({
25200
- signature: outcome.signature,
25201
- timestamp: contract.timestamp,
25202
- contractVersion: contract.contractVersion,
25203
- contractType: asContractType(contract.contractType),
25204
- });
25445
+ // The acceptance signature is already known to be 65 bytes, but the
25446
+ // encoder also reads `timestamp`, `contractVersion` and
25447
+ // `contractType` off a response the SDK did not produce. `start()`
25448
+ // promises it does not reject, so anything that throws in here becomes
25449
+ // a failure the consumer can render rather than an unhandled rejection
25450
+ // in an application that was told it never had to catch one.
25451
+ let depositData;
25452
+ try {
25453
+ depositData = encodeDepositData({
25454
+ signature: outcome.signature,
25455
+ timestamp: contract.timestamp,
25456
+ contractVersion: contract.contractVersion,
25457
+ contractType: asContractType(contract.contractType),
25458
+ });
25459
+ }
25460
+ catch (err) {
25461
+ this._fail(token, approvalRequired, {
25462
+ step: 'request',
25463
+ reason: 'failed',
25464
+ error: err,
25465
+ });
25466
+ return;
25467
+ }
25205
25468
  // 6. Approve — the EXACT amount, never `MaxUint256`. House rule: an
25206
25469
  // unlimited allowance outlives the deposit it was granted for, and a
25207
25470
  // later exploit of the spender would drain a wallet that has long
@@ -26901,6 +27164,8 @@ exports.EPOCHS_IN_YEAR = EPOCHS_IN_YEAR;
26901
27164
  exports.Flow = Flow;
26902
27165
  exports.FlowStore = FlowStore;
26903
27166
  exports.FlowsFacade = FlowsFacade;
27167
+ exports.INVALID_ACCEPTANCE_SIGNATURE_MESSAGE = INVALID_ACCEPTANCE_SIGNATURE_MESSAGE;
27168
+ exports.INVALID_AUTH_SIGNATURE_MESSAGE = INVALID_AUTH_SIGNATURE_MESSAGE;
26904
27169
  exports.INVOICEMATE = INVOICEMATE;
26905
27170
  exports.Kasu = Kasu;
26906
27171
  exports.KasuSdk = KasuSdk;
@@ -26930,11 +27195,13 @@ exports.classifyWalletFailure = classifyWalletFailure;
26930
27195
  exports.compareTrancheSeniority = compareTrancheSeniority;
26931
27196
  exports.computeSettlementWindow = computeSettlementWindow;
26932
27197
  exports.countSubmissions = countSubmissions;
27198
+ exports.decodeRevert = decodeRevert;
26933
27199
  exports.deriveCycleDates = deriveCycleDates;
26934
27200
  exports.derivePoolStatus = derivePoolStatus;
26935
27201
  exports.deriveRequestState = deriveRequestState;
26936
27202
  exports.encodeDepositData = encodeDepositData;
26937
27203
  exports.epochRateToApy = epochRateToApy;
27204
+ exports.extractRevertData = extractRevertData;
26938
27205
  exports.fetchUnusedPoolIds = fetchUnusedPoolIds;
26939
27206
  exports.firstSubmissionTimestamp = firstSubmissionTimestamp;
26940
27207
  exports.floorToCents = floorToCents;