@kasufinance/kasu-sdk 2.4.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -253
  3. package/dist/bundle.cjs.js +5472 -1710
  4. package/dist/bundle.esm.js +5415 -1713
  5. package/dist/domain/au-minimum.d.ts +135 -0
  6. package/dist/domain/au-minimum.js +154 -0
  7. package/dist/domain/au-minimum.js.map +1 -0
  8. package/dist/domain/au-minimum.test.js +202 -0
  9. package/dist/domain/au-minimum.test.js.map +1 -0
  10. package/dist/domain/deposit-bounds.d.ts +62 -0
  11. package/dist/domain/deposit-bounds.js +84 -0
  12. package/dist/domain/deposit-bounds.js.map +1 -0
  13. package/dist/domain/deposit-bounds.test.d.ts +1 -0
  14. package/dist/domain/deposit-bounds.test.js +135 -0
  15. package/dist/domain/deposit-bounds.test.js.map +1 -0
  16. package/dist/domain/index.d.ts +40 -0
  17. package/dist/domain/index.js +33 -0
  18. package/dist/domain/index.js.map +1 -0
  19. package/dist/domain/loan-contract.d.ts +174 -0
  20. package/dist/domain/loan-contract.js +160 -0
  21. package/dist/domain/loan-contract.js.map +1 -0
  22. package/dist/domain/loan-contract.test.d.ts +1 -0
  23. package/dist/domain/loan-contract.test.js +255 -0
  24. package/dist/domain/loan-contract.test.js.map +1 -0
  25. package/dist/domain/partners.d.ts +34 -0
  26. package/dist/domain/partners.js +43 -0
  27. package/dist/domain/partners.js.map +1 -0
  28. package/dist/domain/partners.test.d.ts +1 -0
  29. package/dist/domain/partners.test.js +53 -0
  30. package/dist/domain/partners.test.js.map +1 -0
  31. package/dist/domain/pools.d.ts +70 -0
  32. package/dist/domain/pools.js +134 -0
  33. package/dist/domain/pools.js.map +1 -0
  34. package/dist/domain/pools.test.d.ts +1 -0
  35. package/dist/domain/pools.test.js +184 -0
  36. package/dist/domain/pools.test.js.map +1 -0
  37. package/dist/domain/rates.d.ts +83 -0
  38. package/dist/domain/rates.js +120 -0
  39. package/dist/domain/rates.js.map +1 -0
  40. package/dist/domain/rates.test.d.ts +1 -0
  41. package/dist/domain/rates.test.js +181 -0
  42. package/dist/domain/rates.test.js.map +1 -0
  43. package/dist/domain/requests.d.ts +181 -0
  44. package/dist/domain/requests.js +202 -0
  45. package/dist/domain/requests.js.map +1 -0
  46. package/dist/domain/requests.test.d.ts +1 -0
  47. package/dist/domain/requests.test.js +470 -0
  48. package/dist/domain/requests.test.js.map +1 -0
  49. package/dist/domain/settlement.d.ts +97 -0
  50. package/dist/domain/settlement.js +117 -0
  51. package/dist/domain/settlement.js.map +1 -0
  52. package/dist/domain/settlement.test.d.ts +1 -0
  53. package/dist/domain/settlement.test.js +152 -0
  54. package/dist/domain/settlement.test.js.map +1 -0
  55. package/dist/domain/tranche-display-name.d.ts +17 -0
  56. package/dist/domain/tranche-display-name.js +30 -0
  57. package/dist/domain/tranche-display-name.js.map +1 -0
  58. package/dist/domain/tranche-display-name.test.d.ts +1 -0
  59. package/dist/domain/tranche-display-name.test.js +58 -0
  60. package/dist/domain/tranche-display-name.test.js.map +1 -0
  61. package/dist/domain/tranches.d.ts +89 -0
  62. package/dist/domain/tranches.js +150 -0
  63. package/dist/domain/tranches.js.map +1 -0
  64. package/dist/domain/tranches.test.d.ts +1 -0
  65. package/dist/domain/tranches.test.js +206 -0
  66. package/dist/domain/tranches.test.js.map +1 -0
  67. package/dist/domain/wallet-errors.d.ts +37 -0
  68. package/dist/domain/wallet-errors.js +56 -0
  69. package/dist/domain/wallet-errors.js.map +1 -0
  70. package/dist/domain/wallet-errors.test.d.ts +1 -0
  71. package/dist/domain/wallet-errors.test.js +71 -0
  72. package/dist/domain/wallet-errors.test.js.map +1 -0
  73. package/dist/facade/chain-configs.js +61 -1
  74. package/dist/facade/chain-configs.js.map +1 -1
  75. package/dist/facade/config.test.d.ts +1 -0
  76. package/dist/facade/config.test.js +216 -0
  77. package/dist/facade/config.test.js.map +1 -0
  78. package/dist/facade/deposits.d.ts +22 -1
  79. package/dist/facade/deposits.js +24 -1
  80. package/dist/facade/deposits.js.map +1 -1
  81. package/dist/facade/facade.test.d.ts +1 -0
  82. package/dist/{tests → facade}/facade.test.js +87 -5
  83. package/dist/facade/facade.test.js.map +1 -0
  84. package/dist/facade/index.d.ts +2 -1
  85. package/dist/facade/index.js +2 -0
  86. package/dist/facade/index.js.map +1 -1
  87. package/dist/facade/kasu.d.ts +49 -8
  88. package/dist/facade/kasu.js +117 -13
  89. package/dist/facade/kasu.js.map +1 -1
  90. package/dist/facade/strategies.d.ts +25 -0
  91. package/dist/facade/strategies.js +36 -0
  92. package/dist/facade/strategies.js.map +1 -1
  93. package/dist/facade/types.d.ts +57 -2
  94. package/dist/facade/unused-pool-ids.d.ts +31 -0
  95. package/dist/facade/unused-pool-ids.js +57 -0
  96. package/dist/facade/unused-pool-ids.js.map +1 -0
  97. package/dist/facade/user-portfolio.d.ts +18 -0
  98. package/dist/facade/user-portfolio.js +23 -0
  99. package/dist/facade/user-portfolio.js.map +1 -1
  100. package/dist/index.d.ts +2 -0
  101. package/dist/index.js +6 -0
  102. package/dist/index.js.map +1 -1
  103. package/dist/sdk-config.d.ts +11 -0
  104. package/dist/sdk-config.js +8 -1
  105. package/dist/sdk-config.js.map +1 -1
  106. package/dist/services/DataService/data-service.d.ts +5 -0
  107. package/dist/services/DataService/data-service.js +10 -12
  108. package/dist/services/DataService/data-service.js.map +1 -1
  109. package/dist/services/DataService/directus-client.d.ts +26 -0
  110. package/dist/services/DataService/directus-client.js +38 -0
  111. package/dist/services/DataService/directus-client.js.map +1 -0
  112. package/dist/services/Locking/calculate-apy.test.d.ts +1 -0
  113. package/dist/services/Locking/calculate-apy.test.js +41 -0
  114. package/dist/services/Locking/calculate-apy.test.js.map +1 -0
  115. package/dist/services/Locking/locking.d.ts +14 -0
  116. package/dist/services/Locking/locking.js +16 -2
  117. package/dist/services/Locking/locking.js.map +1 -1
  118. package/dist/services/UserLending/user-lending.js +11 -7
  119. package/dist/services/UserLending/user-lending.js.map +1 -1
  120. package/dist/tests/sample.test.js +46 -25
  121. package/dist/tests/sample.test.js.map +1 -1
  122. package/package.json +43 -12
  123. package/src/domain/au-minimum.test.ts +371 -0
  124. package/src/domain/au-minimum.ts +192 -0
  125. package/src/domain/deposit-bounds.test.ts +200 -0
  126. package/src/domain/deposit-bounds.ts +118 -0
  127. package/src/domain/index.ts +134 -0
  128. package/src/domain/loan-contract.test.ts +343 -0
  129. package/src/domain/loan-contract.ts +275 -0
  130. package/src/domain/partners.test.ts +83 -0
  131. package/src/domain/partners.ts +66 -0
  132. package/src/domain/pools.test.ts +260 -0
  133. package/src/domain/pools.ts +150 -0
  134. package/src/domain/rates.test.ts +254 -0
  135. package/src/domain/rates.ts +120 -0
  136. package/src/domain/requests.test.ts +653 -0
  137. package/src/domain/requests.ts +414 -0
  138. package/src/domain/settlement.test.ts +198 -0
  139. package/src/domain/settlement.ts +161 -0
  140. package/src/domain/tranche-display-name.test.ts +96 -0
  141. package/src/domain/tranche-display-name.ts +34 -0
  142. package/src/domain/tranches.test.ts +300 -0
  143. package/src/domain/tranches.ts +177 -0
  144. package/src/domain/wallet-errors.test.ts +100 -0
  145. package/src/domain/wallet-errors.ts +56 -0
  146. package/src/facade/chain-configs.ts +62 -1
  147. package/src/facade/config.test.ts +265 -0
  148. package/src/facade/deposits.ts +25 -0
  149. package/src/{tests → facade}/facade.test.ts +134 -5
  150. package/src/facade/index.ts +4 -0
  151. package/src/facade/kasu.ts +155 -12
  152. package/src/facade/strategies.ts +36 -0
  153. package/src/facade/types.ts +60 -2
  154. package/src/facade/unused-pool-ids.ts +59 -0
  155. package/src/facade/user-portfolio.ts +24 -0
  156. package/src/index.ts +8 -0
  157. package/src/sdk-config.ts +19 -1
  158. package/src/services/DataService/data-service.ts +14 -26
  159. package/src/services/DataService/directus-client.ts +54 -0
  160. package/src/services/Locking/calculate-apy.test.ts +52 -0
  161. package/src/services/Locking/locking.ts +16 -2
  162. package/src/services/UserLending/user-lending.ts +17 -21
  163. package/src/tests/sample.test.ts +54 -33
  164. package/.eslintignore +0 -3
  165. package/.eslintrc.json +0 -90
  166. package/.github/workflows/ci.yml +0 -39
  167. package/.github/workflows/gitleaks.yml +0 -22
  168. package/.github/workflows/main.yml +0 -41
  169. package/.gitleaksignore +0 -19
  170. package/.prettierignore +0 -3
  171. package/.prettierrc +0 -28
  172. package/CLAUDE.md +0 -410
  173. package/copy-abi.ps1 +0 -15
  174. package/dist/tests/facade.test.js.map +0 -1
  175. package/index.ts +0 -2
  176. package/jest-config.ts +0 -9
  177. package/jest.config.js +0 -8
  178. package/rollup.config.js +0 -45
  179. package/tsconfig.json +0 -27
  180. /package/dist/{tests/facade.test.d.ts → domain/au-minimum.test.d.ts} +0 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The AU cumulative-lending minimum — the numeric half.
3
+ *
4
+ * RULE: a lender whose verified KYC country is Australia may only lend when
5
+ * their existing deposited position on THIS deployment, plus the amount they
6
+ * are asking for, reaches the deployment's threshold.
7
+ *
8
+ * This is UX PRE-VALIDATION ONLY. kasu-backend enforces the rule
9
+ * authoritatively at contract generation and refuses with HTTP 403
10
+ * `AU_WHOLESALE_MINIMUM_NOT_MET`; a lender who gets past this check is still
11
+ * stopped there. What these helpers exist for is to raise the amount field's
12
+ * minimum so the lender learns the rule while typing rather than at the end.
13
+ *
14
+ * FAIL-OPEN by design: an absent or unknown country is NOT restricted, and an
15
+ * unlisted stable symbol has no threshold. An UNKNOWN existing position is
16
+ * treated as 0 — strict, so the floor is never advertised lower than the
17
+ * backend will accept, and it relaxes once the position loads.
18
+ *
19
+ * Arithmetic is integer bigint in minor units (10^decimals) so no two
20
+ * consumers can disagree at the boundary. Parsing TRUNCATES, never rounds up —
21
+ * a position can never be inflated into passing. `BigInt(...)` calls only, no
22
+ * bigint literals, so consumers on an older target still compile.
23
+ *
24
+ * Lifted from kasu-ui's `features/lending/lib/au-lending-restriction.ts`. The
25
+ * two toast strings that live there are copy and stay in the applications.
26
+ */
27
+
28
+ type Maybe<T> = T | null | undefined;
29
+ type NumLike = string | number | null | undefined;
30
+
31
+ const ZERO = BigInt(0);
32
+
33
+ /** ISO 3166-1 alpha-3 for Australia — the collapsed KYC country field. */
34
+ export const AU_ALPHA3 = 'AUS';
35
+
36
+ /**
37
+ * Is this the Australian KYC country? Case-insensitive and
38
+ * whitespace-tolerant. Anything that is not a string — including the
39
+ * `undefined` of an unloaded KYC record — is not restricted (fail-open).
40
+ *
41
+ * The alpha-2 `'AU'` deliberately does NOT match: the KYC country reaching
42
+ * this rule is normalised to alpha-3 upstream, and a bare two-letter code
43
+ * here means something else went wrong.
44
+ */
45
+ export function isAustralianKyc(country: Maybe<string>): boolean {
46
+ if (typeof country !== 'string') return false;
47
+ return country.trim().toUpperCase() === AU_ALPHA3;
48
+ }
49
+
50
+ /**
51
+ * Minimum CUMULATIVE position, in WHOLE stable units, keyed on the
52
+ * deployment's stable-asset symbol (a property of the currency, not the
53
+ * chain). Must match kasu-backend's table exactly.
54
+ */
55
+ export const AU_MIN_CUMULATIVE_BY_STABLE: Readonly<Record<string, number>> = {
56
+ USDC: 360_000,
57
+ AUDD: 500_000,
58
+ };
59
+
60
+ /**
61
+ * Threshold in whole units, or `undefined` for an unlisted stable — a
62
+ * deployment whose currency has no configured minimum is unrestricted.
63
+ */
64
+ export function auThresholdFor(symbol: Maybe<string>): number | undefined {
65
+ if (typeof symbol !== 'string') return undefined;
66
+ return AU_MIN_CUMULATIVE_BY_STABLE[symbol.trim().toUpperCase()];
67
+ }
68
+
69
+ /**
70
+ * Is this wallet released from the minimum entirely, whatever its country,
71
+ * stable or position says?
72
+ *
73
+ * `exemptAddresses` is DEPLOYMENT CONFIGURATION the caller supplies, not a
74
+ * constant of this package. kasu-sdk is published publicly; the exempt list is
75
+ * a compliance carve-out naming particular lender wallets, so it does not
76
+ * belong in a public tarball. Read it from wherever the application keeps its
77
+ * deployment configuration, and keep it identical to kasu-backend's
78
+ * `AU_WHOLESALE_EXEMPT_ADDRESSES` — that is where the rule is actually
79
+ * enforced. An address exempt here but not there sees no raised minimum in the
80
+ * form and is refused at the end, which is worse than not exempting it at all.
81
+ *
82
+ * The address matched must be the CONNECTED wallet — the one that owns the KYC
83
+ * record and signs the agreement request. kasu-backend verifies that signature
84
+ * before the gate, so an exemption cannot be claimed by asserting someone
85
+ * else's address; it takes their private key. Never match a "view as" address.
86
+ *
87
+ * Comparison is case-insensitive and whitespace-tolerant. Anything that is not
88
+ * a string is NOT exempt (fail closed), and so is anything when the list is
89
+ * absent.
90
+ */
91
+ export function isAuMinimumExempt(
92
+ address: Maybe<string>,
93
+ exemptAddresses: Maybe<readonly string[]>,
94
+ ): boolean {
95
+ if (typeof address !== 'string' || !exemptAddresses) return false;
96
+ const needle = address.trim().toLowerCase();
97
+ return exemptAddresses.some((a) => a.trim().toLowerCase() === needle);
98
+ }
99
+
100
+ const DECIMAL_RE = /^(\d+)?(?:\.(\d*))?$/;
101
+
102
+ /**
103
+ * Truncating, partial-input-tolerant decimal → minor-unit parse.
104
+ * `'10.'` → `10000000n` · `'1.23456789'` → `1234567n` (truncated at 6dp).
105
+ * `''` / `'abc'` / negative / null → `null`.
106
+ *
107
+ * Tolerant of mid-typing states because it runs on an amount field as the
108
+ * lender types, and truncating because rounding up would let a position that
109
+ * is a fraction short read as passing.
110
+ */
111
+ export function parseMinorUnits(
112
+ value: NumLike,
113
+ decimals: number,
114
+ ): bigint | null {
115
+ if (value === null || value === undefined) return null;
116
+ const raw = typeof value === 'number' ? String(value) : value.trim();
117
+ if (!raw) return null;
118
+ const match = DECIMAL_RE.exec(raw);
119
+ if (!match || (!match[1] && !match[2])) return null;
120
+ // Both capture groups are optional, so either can be absent at runtime.
121
+ // A fractional group that matched EMPTY ('10.') is not absent, and the
122
+ // destructuring defaults leave it alone — only `undefined` takes them.
123
+ const [, whole = '0', fracDigits = ''] = match;
124
+ const frac = fracDigits.slice(0, decimals).padEnd(decimals, '0');
125
+ return BigInt(whole + frac);
126
+ }
127
+
128
+ export interface AuMinimumInput {
129
+ /** Collapsed KYC country (alpha-3). Missing/undefined ⇒ not restricted. */
130
+ country: Maybe<string>;
131
+ /**
132
+ * The CONNECTED wallet (never a "view as" override).
133
+ *
134
+ * REQUIRED KEY, nullable value. A caller with no wallet passes `undefined`
135
+ * and gets the un-exempted behaviour; what it may not do is silently omit
136
+ * the key. Optional would let a refactor of the call site drop it with no
137
+ * compile error — and because a form's own minimum validation refuses a
138
+ * submit below the floor, an exempt lender would then be stopped HERE and
139
+ * never reach the backend that would have let them through.
140
+ */
141
+ address: Maybe<string>;
142
+ /**
143
+ * The deployment's exempt wallets — see `isAuMinimumExempt`. REQUIRED KEY
144
+ * for the same reason `address` is: a caller that has no list passes
145
+ * `undefined` deliberately, and no refactor can drop it by accident.
146
+ */
147
+ exemptAddresses: Maybe<readonly string[]>;
148
+ /** Deployment stable-asset symbol, e.g. `'USDC'` / `'AUDD'`. */
149
+ stableSymbol: Maybe<string>;
150
+ /** Stable-asset decimals (6 on every current deployment). */
151
+ decimals: number;
152
+ /**
153
+ * Existing position (active + pending) on THIS deployment, whole units.
154
+ * Unparseable/undefined ⇒ treated as 0 (strict: the full threshold applies
155
+ * until the position is known).
156
+ */
157
+ existingDeposited: NumLike;
158
+ }
159
+
160
+ /**
161
+ * The amount, in WHOLE stable units, an Australian lender still needs in order
162
+ * to reach the deployment's cumulative minimum. Apply it as the amount field's
163
+ * floor via `max(trancheMin, auMinimumRemaining(...))`.
164
+ *
165
+ * Returns 0 for everyone the rule does not restrict (fail-open), 0 for an
166
+ * exempt address, and 0 once the lender's existing position already satisfies
167
+ * the threshold.
168
+ *
169
+ * The exemption is tested FIRST and unconditionally: an exempt wallet has no
170
+ * minimum whatever its country, stable or position says. kasu-backend
171
+ * deliberately tests country first and the exemption second — the two orders
172
+ * are not in conflict, both return "no minimum" for the same inputs. There,
173
+ * the exemption sets a flag that drives a compliance log, so it must not fire
174
+ * for a lender the rule never engaged for. Here nothing is logged, so
175
+ * exemption-first is preferred: it makes this function right on its own,
176
+ * whatever country a caller happens to pass.
177
+ */
178
+ export function auMinimumRemaining(input: AuMinimumInput): number {
179
+ if (isAuMinimumExempt(input.address, input.exemptAddresses)) return 0;
180
+ if (!isAustralianKyc(input.country)) return 0;
181
+ const threshold = auThresholdFor(input.stableSymbol);
182
+ if (threshold === undefined) return 0;
183
+ const { existingDeposited, decimals } = input;
184
+ // A table threshold always parses; `?? ZERO` keeps the rule fail-open
185
+ // rather than asserting, so an unparseable one yields no minimum at all.
186
+ const thresholdMinor = parseMinorUnits(threshold, decimals) ?? ZERO;
187
+ const existingMinor = parseMinorUnits(existingDeposited, decimals) ?? ZERO;
188
+ const remaining = thresholdMinor - existingMinor;
189
+ if (remaining <= ZERO) return 0;
190
+ const base = BigInt('1' + '0'.repeat(decimals));
191
+ return Number(remaining / base) + Number(remaining % base) / Number(base);
192
+ }
@@ -0,0 +1,200 @@
1
+ import * as fc from 'fast-check';
2
+
3
+ import { TrancheData } from '../services/DataService/types';
4
+
5
+ import {
6
+ ceilToCents,
7
+ floorToCents,
8
+ isBelowMinimumCapacity,
9
+ MAX_LENDING_AMOUNT_FALLBACK,
10
+ MIN_LENDING_AMOUNT_FALLBACK,
11
+ parseTrancheBound,
12
+ resolveBoundShortcuts,
13
+ resolveDepositBounds,
14
+ } from './deposit-bounds';
15
+
16
+ /**
17
+ * Ported from kasu-ui `src/features/lending/lib/tranche-bounds.property.test.ts`,
18
+ * with the `resolveDepositBounds` / `parseTrancheBound` cases the property file
19
+ * did not cover.
20
+ *
21
+ * The Min/Max shortcuts (clickable bound labels on a lend form) must never
22
+ * write an amount the form rejects, so the snapping is checked as invariants
23
+ * over the whole input domain rather than at a few hand-picked values.
24
+ */
25
+
26
+ function tranche(over: Partial<TrancheData>): TrancheData {
27
+ return {
28
+ minimumDeposit: '500',
29
+ maximumDeposit: '500000',
30
+ poolCapacity: '250000',
31
+ ...over,
32
+ } as unknown as TrancheData;
33
+ }
34
+
35
+ const amountArb = fc.double({
36
+ min: 0,
37
+ max: 1e12,
38
+ noNaN: true,
39
+ noDefaultInfinity: true,
40
+ });
41
+ /** `String(k / 100)` prints at most two decimals for any integer `k` in range. */
42
+ const AT_MOST_TWO_DP = /^\d+(\.\d{1,2})?$/;
43
+
44
+ describe('parseTrancheBound', () => {
45
+ it('accepts a positive finite bound from a string or a number', () => {
46
+ expect(parseTrancheBound('500')).toBe(500);
47
+ expect(parseTrancheBound(500)).toBe(500);
48
+ });
49
+
50
+ it('rejects anything that is not a usable bound', () => {
51
+ expect(parseTrancheBound(null)).toBeNull();
52
+ expect(parseTrancheBound(undefined)).toBeNull();
53
+ expect(parseTrancheBound('')).toBeNull();
54
+ expect(parseTrancheBound('0')).toBeNull();
55
+ expect(parseTrancheBound('-1')).toBeNull();
56
+ expect(parseTrancheBound('not-a-number')).toBeNull();
57
+ expect(parseTrancheBound(Infinity)).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe('resolveDepositBounds', () => {
62
+ it('clamps the max by the tranche’s REMAINING capacity', () => {
63
+ // A tranche can fill mid-epoch while `maximumDeposit` stays put.
64
+ expect(
65
+ resolveDepositBounds(
66
+ tranche({ maximumDeposit: '500000', poolCapacity: '25285.86' }),
67
+ ),
68
+ ).toEqual({ minDeposit: 500, maxDeposit: 25285.86 });
69
+ });
70
+
71
+ it('keeps the configured max when capacity is larger', () => {
72
+ expect(
73
+ resolveDepositBounds(
74
+ tranche({ maximumDeposit: '500000', poolCapacity: '900000' }),
75
+ ),
76
+ ).toEqual({ minDeposit: 500, maxDeposit: 500000 });
77
+ });
78
+
79
+ it('falls back when a "coming soon" pool has no bounds configured', () => {
80
+ expect(
81
+ resolveDepositBounds(
82
+ tranche({
83
+ minimumDeposit: '0',
84
+ maximumDeposit: '0',
85
+ poolCapacity: '900000',
86
+ }),
87
+ ),
88
+ ).toEqual({
89
+ minDeposit: MIN_LENDING_AMOUNT_FALLBACK,
90
+ maxDeposit: MAX_LENDING_AMOUNT_FALLBACK,
91
+ });
92
+ });
93
+
94
+ it('uses both fallbacks and skips the capacity clamp with no tranche at all', () => {
95
+ expect(resolveDepositBounds(undefined)).toEqual({
96
+ minDeposit: MIN_LENDING_AMOUNT_FALLBACK,
97
+ maxDeposit: MAX_LENDING_AMOUNT_FALLBACK,
98
+ });
99
+ });
100
+
101
+ it('treats unparseable or negative capacity as zero', () => {
102
+ expect(
103
+ resolveDepositBounds(tranche({ poolCapacity: 'not-a-number' }))
104
+ .maxDeposit,
105
+ ).toBe(0);
106
+ expect(
107
+ resolveDepositBounds(tranche({ poolCapacity: '-5' })).maxDeposit,
108
+ ).toBe(0);
109
+ });
110
+ });
111
+
112
+ describe('cent snapping', () => {
113
+ it('floorToCents never exceeds its input, stays within a cent of it, and has ≤ 2dp', () => {
114
+ fc.assert(
115
+ fc.property(amountArb, (n) => {
116
+ const snapped = floorToCents(n);
117
+ expect(snapped).toBeLessThanOrEqual(n);
118
+ expect(n - snapped).toBeLessThan(0.02);
119
+ expect(String(snapped)).toMatch(AT_MOST_TWO_DP);
120
+ }),
121
+ );
122
+ });
123
+
124
+ it('ceilToCents never undershoots its input, stays within a cent of it, and has ≤ 2dp', () => {
125
+ fc.assert(
126
+ fc.property(amountArb, (n) => {
127
+ const snapped = ceilToCents(n);
128
+ expect(snapped).toBeGreaterThanOrEqual(n);
129
+ expect(snapped - n).toBeLessThan(0.02);
130
+ expect(String(snapped)).toMatch(AT_MOST_TWO_DP);
131
+ }),
132
+ );
133
+ });
134
+ });
135
+
136
+ describe('resolveBoundShortcuts', () => {
137
+ it('keeps both shortcuts inside the validated range whenever they are enabled', () => {
138
+ fc.assert(
139
+ fc.property(amountArb, amountArb, (minDeposit, maxDeposit) => {
140
+ const { min, max, enabled } = resolveBoundShortcuts(
141
+ minDeposit,
142
+ maxDeposit,
143
+ );
144
+ if (!enabled) return;
145
+ expect(min).toBeGreaterThanOrEqual(minDeposit);
146
+ expect(max).toBeLessThanOrEqual(maxDeposit);
147
+ expect(max).toBeGreaterThanOrEqual(min);
148
+ expect(max).toBeGreaterThan(0);
149
+ }),
150
+ );
151
+ });
152
+
153
+ it('disables the pair whenever the snapped range is empty', () => {
154
+ // Tranche at capacity (max clamps to 0), sub-cent dust the SDK's float
155
+ // subtraction leaves behind, remaining capacity below the tranche
156
+ // minimum, and an AU floor above the capacity.
157
+ expect(resolveBoundShortcuts(500, 0).enabled).toBe(false);
158
+ expect(
159
+ resolveBoundShortcuts(500, 1.4551915228366852e-11).enabled,
160
+ ).toBe(false);
161
+ expect(resolveBoundShortcuts(500, 137).enabled).toBe(false);
162
+ expect(resolveBoundShortcuts(240_000, 100_000).enabled).toBe(false);
163
+ });
164
+
165
+ it('snaps SDK float noise onto cents without crossing the bound', () => {
166
+ expect(resolveBoundShortcuts(500, 249999.99999999997)).toEqual({
167
+ min: 500,
168
+ max: 249999.99,
169
+ enabled: true,
170
+ });
171
+ expect(resolveBoundShortcuts(500, 25285.86)).toEqual({
172
+ min: 500,
173
+ max: 25285.86,
174
+ enabled: true,
175
+ });
176
+ // An AU remainder with more precision than the field shows rounds UP.
177
+ expect(resolveBoundShortcuts(358765.432109, 500_000).min).toBe(
178
+ 358765.44,
179
+ );
180
+ });
181
+ });
182
+
183
+ describe('isBelowMinimumCapacity', () => {
184
+ it('is exactly the negation of the shortcuts being enabled', () => {
185
+ fc.assert(
186
+ fc.property(amountArb, amountArb, (minDeposit, maxDeposit) => {
187
+ // The "nothing to click" and "nothing to type" states must
188
+ // never be able to disagree.
189
+ expect(isBelowMinimumCapacity(minDeposit, maxDeposit)).toBe(
190
+ !resolveBoundShortcuts(minDeposit, maxDeposit).enabled,
191
+ );
192
+ }),
193
+ );
194
+ });
195
+
196
+ it('flags a capacity that has fallen under the tranche minimum', () => {
197
+ expect(isBelowMinimumCapacity(500, 137)).toBe(true);
198
+ expect(isBelowMinimumCapacity(500, 25285.86)).toBe(false);
199
+ });
200
+ });
@@ -0,0 +1,118 @@
1
+ import { TrancheData } from '../services/DataService/types';
2
+
3
+ // Fallbacks if the selected tranche's on-chain min/max are 0 / NaN (some
4
+ // "coming soon" pools haven't been configured yet).
5
+ export const MIN_LENDING_AMOUNT_FALLBACK = 500;
6
+ export const MAX_LENDING_AMOUNT_FALLBACK = 500_000;
7
+
8
+ export function parseTrancheBound(
9
+ raw: string | number | null | undefined,
10
+ ): number | null {
11
+ if (raw == null) return null;
12
+ const n = typeof raw === 'string' ? parseFloat(raw) : raw;
13
+ return Number.isFinite(n) && n > 0 ? n : null;
14
+ }
15
+
16
+ export interface DepositBounds {
17
+ minDeposit: number;
18
+ maxDeposit: number;
19
+ }
20
+
21
+ /**
22
+ * Min + max lending amount (in whole stable units) for a tranche. The max is
23
+ * clamped by the tranche's *remaining* capacity: a tranche can fill mid-epoch
24
+ * while `maximumDeposit` stays at its configured value, so without the clamp
25
+ * a form would accept an amount the contract will reject. Mirrors
26
+ * kasu-fe-next's `calculateDepositMinMax` (`trancheMax = min(max, capacity)`).
27
+ */
28
+ export function resolveDepositBounds(
29
+ tranche: TrancheData | undefined,
30
+ ): DepositBounds {
31
+ const minDeposit =
32
+ parseTrancheBound(tranche?.minimumDeposit) ??
33
+ MIN_LENDING_AMOUNT_FALLBACK;
34
+ const configuredMax =
35
+ parseTrancheBound(tranche?.maximumDeposit) ??
36
+ MAX_LENDING_AMOUNT_FALLBACK;
37
+ if (!tranche) return { minDeposit, maxDeposit: configuredMax };
38
+ const remainingCapacity = Math.max(
39
+ 0,
40
+ parseFloat(tranche.poolCapacity) || 0,
41
+ );
42
+ return { minDeposit, maxDeposit: Math.min(configuredMax, remainingCapacity) };
43
+ }
44
+
45
+ export interface BoundShortcuts {
46
+ min: number;
47
+ max: number;
48
+ enabled: boolean;
49
+ }
50
+
51
+ /**
52
+ * The values a lend form's clickable Min/Max labels write into the amount
53
+ * field. Derived from the validated bounds but snapped to whole cents
54
+ * CONSERVATIVELY — the min rounds UP, the max rounds DOWN — so a shortcut can
55
+ * never fill an amount the form's own `min ≤ amount ≤ max` check rejects, and
56
+ * never carries the SDK's float noise into the field (`targetDrawAmount -
57
+ * pendingDeposits` routinely yields `249999.99999999997`, which would make the
58
+ * label, the field's 2dp display and the signed request label all disagree).
59
+ *
60
+ * `enabled` is false when the snapped range is empty: a tranche at capacity
61
+ * (max clamps to 0 or to sub-cent dust), a remaining capacity below the
62
+ * tranche minimum, or a jurisdiction floor above the capacity. Nothing either
63
+ * shortcut could write would validate, so both render inert.
64
+ */
65
+ export function resolveBoundShortcuts(
66
+ minDeposit: number,
67
+ maxDeposit: number,
68
+ ): BoundShortcuts {
69
+ const min = ceilToCents(minDeposit);
70
+ const max = floorToCents(maxDeposit);
71
+ const enabled =
72
+ Number.isFinite(min) && Number.isFinite(max) && max > 0 && max >= min;
73
+ return { min, max, enabled };
74
+ }
75
+
76
+ /**
77
+ * True when no amount exists that satisfies both bounds — the minimum sits
78
+ * ABOVE the maximum, so the range a form would otherwise print ("Min 360,000 ·
79
+ * Max 25,285") is a contradiction, not an instruction. Happens when a
80
+ * tranche's remaining capacity falls under its own configured minimum, when it
81
+ * is at capacity (max 0 / sub-cent dust), or when the AU wholesale cumulative
82
+ * floor lands above the capacity that is left.
83
+ *
84
+ * Defined as the negation of `resolveBoundShortcuts().enabled` on purpose: the
85
+ * "there is nothing to click" and "there is nothing to type" states must never
86
+ * be able to disagree.
87
+ *
88
+ * This is UX only. The binding minimum is enforced by kasu-backend (403
89
+ * `AU_WHOLESALE_MINIMUM_NOT_MET`) and by the contract's own bounds; nothing
90
+ * here may be used to relax either.
91
+ */
92
+ export function isBelowMinimumCapacity(
93
+ minDeposit: number,
94
+ maxDeposit: number,
95
+ ): boolean {
96
+ return !resolveBoundShortcuts(minDeposit, maxDeposit).enabled;
97
+ }
98
+
99
+ const CENTS = 100;
100
+
101
+ /**
102
+ * Largest whole-cent value ≤ `n`. Float multiplication can round `n * 100` UP
103
+ * onto an integer (`1.15 * 100 === 114.99999999999999` is the usual direction,
104
+ * but the other happens too), so the result is re-checked against `n` and
105
+ * stepped down a cent if it overshot — the contract is "never above `n`".
106
+ */
107
+ export function floorToCents(n: number): number {
108
+ const k = Math.floor(n * CENTS);
109
+ const snapped = k / CENTS;
110
+ return snapped > n ? (k - 1) / CENTS : snapped;
111
+ }
112
+
113
+ /** Smallest whole-cent value ≥ `n`. Mirror of `floorToCents`: never below `n`. */
114
+ export function ceilToCents(n: number): number {
115
+ const k = Math.ceil(n * CENTS);
116
+ const snapped = k / CENTS;
117
+ return snapped < n ? (k + 1) / CENTS : snapped;
118
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The shared domain layer: the rules every Kasu frontend needs to agree on,
3
+ * in one place instead of three.
4
+ *
5
+ * Two rules govern what may live here.
6
+ *
7
+ * 1. **Numbers and codes only.** No copy, no locale, no `Intl`, no string a
8
+ * user reads — `netEffectiveApy` returns `0.196`, never `'19.60% p.a.'`,
9
+ * and `derivePoolStatus` returns the code `'Full'`, not a sentence. The
10
+ * formatters that turn these into text stay in each app, where the design
11
+ * system and the visitor's locale are.
12
+ *
13
+ * Two kinds of string are exempt, and only these two. `getTrancheDisplayName`
14
+ * is here precisely because that rename must NOT drift between apps. And
15
+ * `loan-contract.ts` builds PROTOCOL strings — messages kasu-backend
16
+ * reconstructs byte-for-byte to verify a lender's signature. Those are not
17
+ * copy: nobody may reword them, in any language, without a matching backend
18
+ * change, which is exactly why they belong in one place.
19
+ * 2. **Pure.** No network, no clock, no environment. Anything with I/O belongs
20
+ * in `facade/` (see `fetchUnusedPoolIds`).
21
+ */
22
+ export {
23
+ apyToEpochRate,
24
+ epochRateToApy,
25
+ EPOCHS_IN_YEAR,
26
+ netEffectiveApy,
27
+ } from './rates';
28
+
29
+ export {
30
+ AU_ALPHA3,
31
+ AU_MIN_CUMULATIVE_BY_STABLE,
32
+ auMinimumRemaining,
33
+ auThresholdFor,
34
+ isAuMinimumExempt,
35
+ isAustralianKyc,
36
+ parseMinorUnits,
37
+ } from './au-minimum';
38
+ export type { AuMinimumInput } from './au-minimum';
39
+
40
+ export {
41
+ ceilToCents,
42
+ floorToCents,
43
+ isBelowMinimumCapacity,
44
+ MAX_LENDING_AMOUNT_FALLBACK,
45
+ MIN_LENDING_AMOUNT_FALLBACK,
46
+ parseTrancheBound,
47
+ resolveBoundShortcuts,
48
+ resolveDepositBounds,
49
+ } from './deposit-bounds';
50
+ export type { BoundShortcuts, DepositBounds } from './deposit-bounds';
51
+
52
+ export {
53
+ APXIUM,
54
+ getCreditOriginator,
55
+ getInstitutionalLender,
56
+ INVOICEMATE,
57
+ RIXON_CAPITAL,
58
+ } from './partners';
59
+ export type { PoolNameSignal, StrategyPartner } from './partners';
60
+
61
+ export {
62
+ asContractType,
63
+ buildContractVersionType,
64
+ buildFullNameRequestMessage,
65
+ buildLegacyContractRequestMessage,
66
+ buildLoanAgreementSignMessage,
67
+ encodeDepositData,
68
+ formatSignTimestampUtc,
69
+ parseFormattedMessage,
70
+ } from './loan-contract';
71
+ export type {
72
+ ContractListItem,
73
+ ContractSection,
74
+ ContractType,
75
+ ExemptLoanContract,
76
+ GenerateContractResponse,
77
+ LoanContractFormatted,
78
+ ResolvedContractResponse,
79
+ RetailLoanContract,
80
+ } from './loan-contract';
81
+
82
+ export {
83
+ maxNetRateCeiling,
84
+ pickHighestYieldTranche,
85
+ poolMaxApy,
86
+ selectVisiblePools,
87
+ } from './pools';
88
+ export type { BestTranche } from './pools';
89
+
90
+ export {
91
+ countSubmissions,
92
+ deriveRequestState,
93
+ firstSubmissionTimestamp,
94
+ isCycleClosed,
95
+ submissionEvents,
96
+ } from './requests';
97
+ export type {
98
+ RequestKind,
99
+ RequestState,
100
+ RequestStatusCode,
101
+ } from './requests';
102
+
103
+ export {
104
+ CLEARING_WINDOW_SECONDS,
105
+ computeSettlementWindow,
106
+ deriveCycleDates,
107
+ nextCycleBoundary,
108
+ } from './settlement';
109
+ export type {
110
+ CycleDates,
111
+ SettlementWindowInput,
112
+ SettlementWindowState,
113
+ } from './settlement';
114
+
115
+ export { getTrancheDisplayName, UPPER_MEZZANINE } from './tranche-display-name';
116
+
117
+ export {
118
+ compareTrancheSeniority,
119
+ derivePoolStatus,
120
+ MIN_TRANCHE_CAPACITY,
121
+ netTrancheApyBounds,
122
+ pickDefaultTrancheId,
123
+ poolAllTranchesFull,
124
+ trancheApyBounds,
125
+ trancheHasCapacity,
126
+ trancheRiskRank,
127
+ } from './tranches';
128
+ export type {
129
+ ApyBounds,
130
+ PoolStatus,
131
+ TrancheCapacitySignal,
132
+ } from './tranches';
133
+
134
+ export { isUnpredictableGas, isUserRejected } from './wallet-errors';