@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,161 @@
1
+ /**
2
+ * Cycles and the clearing window — the protocol's weekly clock, as numbers.
3
+ *
4
+ * Lifted verbatim from kasu-ui's `features/portfolio/lib/settlement-window.ts`
5
+ * and `features/lending/lib/cycle-dates.ts`. The two formatters that live
6
+ * beside `deriveCycleDates` there (`formatCycleDate`, `formatCycleCloseUtc`)
7
+ * print words and stay in the applications; everything here is unix seconds in
8
+ * and unix seconds out.
9
+ *
10
+ * The clearing window is the fixed 48 hours immediately preceding an epoch
11
+ * end. Inside it, pending requests are being processed and cannot be modified,
12
+ * and the countdown runs to the epoch end. Outside it, the countdown runs to
13
+ * the next clearing-window start.
14
+ *
15
+ * The epoch end comes straight from the chain (`nextEpochStartTimestamp`, i.e.
16
+ * the SDK's `getNextEpochDate`) — the same value the protocol's own
17
+ * `getNextClearingPeriodDate` derives from — so the window always lines up
18
+ * with the real weekly schedule (Tue 06:00 → Thu 06:00 UTC on Base) instead of
19
+ * a projected subgraph timestamp that can drift off the grid.
20
+ */
21
+
22
+ /**
23
+ * Clearing-window length — a fixed 48h protocol constant. Exported so every
24
+ * consumer derives the window from the same number this module runs on.
25
+ */
26
+ export const CLEARING_WINDOW_SECONDS = 48 * 60 * 60;
27
+
28
+ /**
29
+ * Weekly cadence — the epoch schedule is fixed weekly (Tue → Thu UTC on Base).
30
+ * Used only to roll a cycle forward when a request lands inside a window that
31
+ * has already closed.
32
+ */
33
+ const WEEK_SECONDS = 7 * 24 * 60 * 60;
34
+
35
+ export type SettlementWindowState =
36
+ | {
37
+ phase: 'awaiting';
38
+ secondsUntilClearing: number;
39
+ nextClearingStart: number;
40
+ }
41
+ | { phase: 'clearing'; secondsUntilEpochEnd: number; epochEnd: number }
42
+ | { phase: 'unknown' };
43
+
44
+ export interface SettlementWindowInput {
45
+ /** Now, in unix seconds. */
46
+ nowSeconds: number;
47
+ /**
48
+ * Authoritative epoch end, in unix seconds — the chain's
49
+ * `nextEpochStartTimestamp` (SDK `getNextEpochDate`). During an epoch this
50
+ * is in the future; it refetches to the next boundary once it rolls.
51
+ */
52
+ nextEpochStart: number;
53
+ /** Clearing-window length; defaults to the 48h protocol constant. */
54
+ clearingWindowSeconds?: number;
55
+ }
56
+
57
+ /**
58
+ * Which phase of the weekly cycle `nowSeconds` falls in, and how long is left
59
+ * of it.
60
+ *
61
+ * `'unknown'` when no epoch boundary has been loaded yet, or when the one on
62
+ * hand is stale (it has already elapsed — the on-chain value refetches to the
63
+ * next boundary shortly after rollover). A caller must render its "no cycle
64
+ * loaded" state there, never a zeroed countdown.
65
+ */
66
+ export function computeSettlementWindow({
67
+ nowSeconds,
68
+ nextEpochStart,
69
+ clearingWindowSeconds = CLEARING_WINDOW_SECONDS,
70
+ }: SettlementWindowInput): SettlementWindowState {
71
+ // No epoch boundary loaded yet, or a stale one that already elapsed.
72
+ if (nextEpochStart <= 0 || nextEpochStart <= nowSeconds) {
73
+ return { phase: 'unknown' };
74
+ }
75
+
76
+ const clearingStart = nextEpochStart - clearingWindowSeconds;
77
+
78
+ if (nowSeconds < clearingStart) {
79
+ return {
80
+ phase: 'awaiting',
81
+ secondsUntilClearing: clearingStart - nowSeconds,
82
+ nextClearingStart: clearingStart,
83
+ };
84
+ }
85
+
86
+ // nowSeconds is in [clearingStart, nextEpochStart) — inside the window.
87
+ return {
88
+ phase: 'clearing',
89
+ secondsUntilEpochEnd: nextEpochStart - nowSeconds,
90
+ epochEnd: nextEpochStart,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * The next cycle boundary strictly after `nowSeconds`, in unix seconds — the
96
+ * cycle close (`nextEpochStart − 48h`) while the window is still open, the
97
+ * epoch end once we are inside it.
98
+ *
99
+ * `undefined` when there is no boundary left to wait for: no epoch boundary
100
+ * loaded, or a cached one that has already elapsed — the same staleness rule
101
+ * `computeSettlementWindow` applies before it reports `'unknown'`.
102
+ *
103
+ * Split out of the state machine because some consumers need the INSTANT
104
+ * rather than the phase: one to flush the cycle-dependent caches when the
105
+ * clock crosses it, one to move a pre-commit screen's snapshot clock at the
106
+ * same moment.
107
+ */
108
+ export function nextCycleBoundary(
109
+ nextEpochStart: number | undefined,
110
+ nowSeconds: number,
111
+ clearingWindowSeconds: number = CLEARING_WINDOW_SECONDS,
112
+ ): number | undefined {
113
+ if (!nextEpochStart || nextEpochStart <= 0) return undefined;
114
+ const clearingStart = nextEpochStart - clearingWindowSeconds;
115
+ if (nowSeconds < clearingStart) return clearingStart;
116
+ if (nowSeconds < nextEpochStart) return nextEpochStart;
117
+ return undefined;
118
+ }
119
+
120
+ export interface CycleDates {
121
+ /** Unix seconds — the next cycle close (clearing-window start). */
122
+ close: number;
123
+ /** Unix seconds — the outcome-confirmed-by boundary (close + 48h). */
124
+ outcome: number;
125
+ }
126
+
127
+ /**
128
+ * The cycle-close and outcome dates for a request submitted `now`.
129
+ *
130
+ * - The cycle "closes" (stops accepting requests, starts processing) at the
131
+ * start of the 48h clearing window, i.e. 48h before the epoch end.
132
+ * - Processing takes up to 48h, so the outcome is confirmed by the epoch end
133
+ * (close + 48h).
134
+ *
135
+ * Returns `null` when the epoch boundary is not available or is stale — a
136
+ * caller then omits the dates entirely (omit, don't stub).
137
+ *
138
+ * The common case is a request submitted OUTSIDE the clearing window: the
139
+ * close is `nextEpochStart − 48h` and the outcome is `nextEpochStart`. When
140
+ * the request lands INSIDE the current clearing window (that close is already
141
+ * in the past), it queues for the NEXT weekly cycle, so the close is advanced
142
+ * by whole weeks until it is in the future.
143
+ */
144
+ export function deriveCycleDates(
145
+ nextEpochStart: number | undefined,
146
+ nowSeconds: number,
147
+ ): CycleDates | null {
148
+ // No boundary loaded, or a stale one that already elapsed — the same
149
+ // staleness rule as `computeSettlementWindow`.
150
+ if (!nextEpochStart || nextEpochStart <= nowSeconds) return null;
151
+
152
+ let close = nextEpochStart - CLEARING_WINDOW_SECONDS;
153
+ let outcome = nextEpochStart;
154
+ // Inside the current clearing window the close already passed; a request
155
+ // now is queued for the next weekly cycle.
156
+ while (close <= nowSeconds) {
157
+ close += WEEK_SECONDS;
158
+ outcome += WEEK_SECONDS;
159
+ }
160
+ return { close, outcome };
161
+ }
@@ -0,0 +1,96 @@
1
+ import { getTrancheDisplayName, UPPER_MEZZANINE } from './tranche-display-name';
2
+
3
+ // Ported from kasu-ui `src/features/lending/lib/tranche-display-name.test.ts`.
4
+
5
+ describe('getTrancheDisplayName', () => {
6
+ it('renames Senior to Upper Mezzanine on Apxium pools', () => {
7
+ expect(
8
+ getTrancheDisplayName('Senior', {
9
+ poolName: 'Taxation Funding (Tax Pay)',
10
+ }),
11
+ ).toBe(UPPER_MEZZANINE);
12
+ expect(
13
+ getTrancheDisplayName('Senior', {
14
+ poolName: 'Professional Fee Funding',
15
+ }),
16
+ ).toBe(UPPER_MEZZANINE);
17
+ expect(
18
+ getTrancheDisplayName('Senior', { poolName: 'Whole Ledger Funding' }),
19
+ ).toBe(UPPER_MEZZANINE);
20
+ });
21
+
22
+ it('keeps Senior on non-Apxium (InvoiceMate) pools', () => {
23
+ expect(
24
+ getTrancheDisplayName('Senior', {
25
+ poolName: 'Payment Finance (PayFi) - Clearing Houses',
26
+ }),
27
+ ).toBe('Senior');
28
+ });
29
+
30
+ it('never touches other tranche names, on any pool', () => {
31
+ expect(
32
+ getTrancheDisplayName('Mezzanine', { poolName: 'Taxation Funding' }),
33
+ ).toBe('Mezzanine');
34
+ expect(
35
+ getTrancheDisplayName('Junior', { poolName: 'Taxation Funding' }),
36
+ ).toBe('Junior');
37
+ expect(
38
+ getTrancheDisplayName('Mezzanine', {
39
+ poolName: 'Payment Finance (PayFi)',
40
+ }),
41
+ ).toBe('Mezzanine');
42
+ });
43
+
44
+ it('matches the Senior name case-insensitively but preserves unknown names verbatim', () => {
45
+ expect(
46
+ getTrancheDisplayName('SENIOR', { poolName: 'Taxation Funding' }),
47
+ ).toBe(UPPER_MEZZANINE);
48
+ expect(
49
+ getTrancheDisplayName('Senior Plus', { poolName: 'Taxation Funding' }),
50
+ ).toBe('Senior Plus');
51
+ });
52
+
53
+ it('treats missing pool names as Apxium (matches getCreditOriginator default)', () => {
54
+ // getCreditOriginator defaults unknown pools to Apxium; the display
55
+ // name must follow the same rule or the two surfaces would disagree.
56
+ expect(getTrancheDisplayName('Senior', { poolName: '' })).toBe(
57
+ UPPER_MEZZANINE,
58
+ );
59
+ expect(getTrancheDisplayName('Senior', {})).toBe(UPPER_MEZZANINE);
60
+ });
61
+ });
62
+
63
+ describe('getTrancheDisplayName — the widened PoolNameSignal', () => {
64
+ it('accepts the facade Strategy shape (`name`)', () => {
65
+ expect(getTrancheDisplayName('Senior', { name: 'Taxation Funding' })).toBe(
66
+ UPPER_MEZZANINE,
67
+ );
68
+ expect(
69
+ getTrancheDisplayName('Senior', {
70
+ name: 'Payment Finance (PayFi)',
71
+ }),
72
+ ).toBe('Senior');
73
+ });
74
+
75
+ it('accepts a bare pool name', () => {
76
+ expect(getTrancheDisplayName('Senior', 'Whole Ledger Funding')).toBe(
77
+ UPPER_MEZZANINE,
78
+ );
79
+ expect(getTrancheDisplayName('Senior', 'Payment Finance (PayFi)')).toBe(
80
+ 'Senior',
81
+ );
82
+ });
83
+
84
+ it('gives the same answer for the raw and facade shapes of one pool', () => {
85
+ for (const poolName of [
86
+ 'Taxation Funding (Tax Pay)',
87
+ 'Payment Finance (PayFi)',
88
+ 'Whole Ledger Funding',
89
+ '',
90
+ ]) {
91
+ expect(getTrancheDisplayName('Senior', { poolName })).toBe(
92
+ getTrancheDisplayName('Senior', { name: poolName }),
93
+ );
94
+ }
95
+ });
96
+ });
@@ -0,0 +1,34 @@
1
+ import { APXIUM, getCreditOriginator, PoolNameSignal } from './partners';
2
+
3
+ // Business rename (2026-07): Apxium strategies market their top retail tranche
4
+ // as "Upper Mezzanine" — the true senior position is held by the institutional
5
+ // lender (Rixon Capital), so retail lenders are never actually senior in the
6
+ // waterfall. Display-only: subgraph/SDK tranche names, ids and any
7
+ // seniority-ranking logic keep the on-chain "Senior" name.
8
+ export const UPPER_MEZZANINE = 'Upper Mezzanine';
9
+
10
+ const SENIOR = 'senior';
11
+
12
+ /**
13
+ * User-facing display name for a tranche. Maps `Senior` → `Upper Mezzanine`
14
+ * on Apxium pools (identified by pool name, see `partners.ts`); every other
15
+ * tranche/pool combination passes through unchanged.
16
+ *
17
+ * Call this at the view-model boundary (options, cards, transaction views),
18
+ * NEVER in matching/sorting logic — `compareTrancheSeniority` and
19
+ * `pickDefaultTrancheId` rank by the raw subgraph name and must keep doing so.
20
+ * This is the one function in `domain/` that returns a user-visible string,
21
+ * and it is here rather than in each frontend so all three cannot drift.
22
+ *
23
+ * `pool` accepts the raw `PoolOverview` (`poolName`), the facade `Strategy`
24
+ * (`name`), or the name itself.
25
+ */
26
+ export function getTrancheDisplayName(
27
+ trancheName: string,
28
+ pool: PoolNameSignal,
29
+ ): string {
30
+ if (trancheName.trim().toLowerCase() !== SENIOR) return trancheName;
31
+ return getCreditOriginator(pool).name === APXIUM.name
32
+ ? UPPER_MEZZANINE
33
+ : trancheName;
34
+ }
@@ -0,0 +1,300 @@
1
+ import * as fc from 'fast-check';
2
+
3
+ import { PoolOverview, TrancheData } from '../services/DataService/types';
4
+
5
+ import { netEffectiveApy } from './rates';
6
+ import {
7
+ compareTrancheSeniority,
8
+ derivePoolStatus,
9
+ MIN_TRANCHE_CAPACITY,
10
+ netTrancheApyBounds,
11
+ pickDefaultTrancheId,
12
+ poolAllTranchesFull,
13
+ trancheApyBounds,
14
+ trancheHasCapacity,
15
+ trancheRiskRank,
16
+ } from './tranches';
17
+
18
+ /**
19
+ * Ported from kasu-ui `pick-default-tranche.test.ts` and the NUMERIC half of
20
+ * `format-tranche-apy.range.test.ts`. The formatting cases — whole-percent
21
+ * rendering, the en-dash shape, the locale table and the "collapse when both
22
+ * bounds print the same" rule — stay in kasu-ui: they are decided on rendered
23
+ * digits, which this layer never produces.
24
+ */
25
+
26
+ // `id === name` keeps assertions readable; `poolCapacity` drives
27
+ // `trancheHasCapacity` (≥ 1 stable unit → selectable).
28
+ function tranche(name: string, poolCapacity: string, apy = '0.12'): TrancheData {
29
+ return { id: name, name, apy, poolCapacity } as unknown as TrancheData;
30
+ }
31
+ function pool(tranches: TrancheData[], enabled = true): PoolOverview {
32
+ return { id: 'p', poolName: 'p', enabled, tranches } as unknown as PoolOverview;
33
+ }
34
+ function rated(minApy: string, maxApy: string): TrancheData {
35
+ return { minApy, maxApy } as unknown as TrancheData;
36
+ }
37
+
38
+ /** The live performance fee on all deployments (subgraph units: 10 = 10%). */
39
+ const FEE = 10;
40
+
41
+ describe('trancheHasCapacity', () => {
42
+ it('accepts a tranche with at least one whole stable unit left', () => {
43
+ expect(trancheHasCapacity({ poolCapacity: '5000' })).toBe(true);
44
+ expect(
45
+ trancheHasCapacity({ poolCapacity: String(MIN_TRANCHE_CAPACITY) }),
46
+ ).toBe(true);
47
+ });
48
+
49
+ it('rejects sub-unit dust, zero and unparseable capacity', () => {
50
+ expect(trancheHasCapacity({ poolCapacity: '0.99' })).toBe(false);
51
+ expect(trancheHasCapacity({ poolCapacity: '0' })).toBe(false);
52
+ expect(
53
+ trancheHasCapacity({ poolCapacity: '1.4551915228366852e-11' }),
54
+ ).toBe(false);
55
+ expect(trancheHasCapacity({ poolCapacity: '' })).toBe(false);
56
+ expect(trancheHasCapacity({ poolCapacity: 'not-a-number' })).toBe(false);
57
+ });
58
+
59
+ it('reads the facade tranche shape (availableCapacity) identically', () => {
60
+ // `StrategyTranche` renames the same number; one gate, both shapes.
61
+ expect(trancheHasCapacity({ availableCapacity: '5000' })).toBe(true);
62
+ expect(trancheHasCapacity({ availableCapacity: '0' })).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe('trancheRiskRank / compareTrancheSeniority', () => {
67
+ it('ranks the waterfall safest-first by the RAW name', () => {
68
+ expect(trancheRiskRank(tranche('Senior', '1'))).toBe(0);
69
+ expect(trancheRiskRank(tranche('Mezzanine', '1'))).toBe(1);
70
+ expect(trancheRiskRank(tranche('Junior', '1'))).toBe(2);
71
+ });
72
+
73
+ it('is case- and whitespace-insensitive, and sorts unknown names last', () => {
74
+ expect(trancheRiskRank(tranche(' SENIOR ', '1'))).toBe(0);
75
+ expect(trancheRiskRank(tranche('Upper Mezzanine', '1'))).toBe(
76
+ Number.POSITIVE_INFINITY,
77
+ );
78
+ });
79
+
80
+ it('sorts Senior → Mezzanine → Junior, keeping ties in input order', () => {
81
+ const tranches = [
82
+ tranche('Junior', '5000'),
83
+ tranche('Tranche B', '5000'),
84
+ tranche('Senior', '5000'),
85
+ tranche('Tranche A', '5000'),
86
+ tranche('Mezzanine', '5000'),
87
+ ];
88
+ expect(
89
+ [...tranches].sort(compareTrancheSeniority).map((t) => t.name),
90
+ ).toEqual([
91
+ 'Senior',
92
+ 'Mezzanine',
93
+ 'Junior',
94
+ 'Tranche B',
95
+ 'Tranche A',
96
+ ]);
97
+ });
98
+ });
99
+
100
+ describe('pickDefaultTrancheId', () => {
101
+ it('defaults to the lowest-risk tranche (Senior) when all have capacity', () => {
102
+ // Subgraph order is Junior → Mezzanine → Senior; the default must NOT
103
+ // be the highest-risk Junior that sits first (audit 3.2).
104
+ const id = pickDefaultTrancheId(
105
+ pool([
106
+ tranche('Junior', '5000'),
107
+ tranche('Mezzanine', '5000'),
108
+ tranche('Senior', '5000'),
109
+ ]),
110
+ );
111
+ expect(id).toBe('Senior');
112
+ });
113
+
114
+ it('picks the lowest-risk tranche that is still AVAILABLE (skips a full Senior)', () => {
115
+ const id = pickDefaultTrancheId(
116
+ pool([
117
+ tranche('Junior', '5000'),
118
+ tranche('Mezzanine', '5000'),
119
+ tranche('Senior', '0'),
120
+ ]),
121
+ );
122
+ expect(id).toBe('Mezzanine');
123
+ });
124
+
125
+ it('does not rank by APY — a Senior priced above Mezzanine is still preferred', () => {
126
+ // Real pools can price Senior above Mezzanine; risk is ranked by name,
127
+ // not rate.
128
+ const id = pickDefaultTrancheId(
129
+ pool([
130
+ tranche('Mezzanine', '5000', '0.15'),
131
+ tranche('Senior', '5000', '0.18'),
132
+ ]),
133
+ );
134
+ expect(id).toBe('Senior');
135
+ });
136
+
137
+ it('falls back to the lowest-risk tranche when every tranche is full', () => {
138
+ const id = pickDefaultTrancheId(
139
+ pool([tranche('Junior', '0'), tranche('Senior', '0')]),
140
+ );
141
+ expect(id).toBe('Senior');
142
+ });
143
+
144
+ it('falls back to the first available tranche when names are unrecognised', () => {
145
+ const id = pickDefaultTrancheId(
146
+ pool([tranche('Tranche B', '5000'), tranche('Tranche A', '5000')]),
147
+ );
148
+ expect(id).toBe('Tranche B');
149
+ });
150
+
151
+ it('returns an empty string for a pool with no tranches', () => {
152
+ expect(pickDefaultTrancheId(pool([]))).toBe('');
153
+ });
154
+ });
155
+
156
+ describe('poolAllTranchesFull', () => {
157
+ it('is true when every tranche lacks remaining capacity', () => {
158
+ expect(
159
+ poolAllTranchesFull(
160
+ pool([tranche('Junior', '0'), tranche('Senior', '0')]),
161
+ ),
162
+ ).toBe(true);
163
+ });
164
+
165
+ it('is false when at least one tranche still has capacity', () => {
166
+ expect(
167
+ poolAllTranchesFull(
168
+ pool([tranche('Junior', '0'), tranche('Senior', '5000')]),
169
+ ),
170
+ ).toBe(false);
171
+ });
172
+
173
+ it('is false for a pool with no tranches (nothing to be full)', () => {
174
+ expect(poolAllTranchesFull(pool([]))).toBe(false);
175
+ });
176
+ });
177
+
178
+ describe('derivePoolStatus', () => {
179
+ it('is "Coming soon" for a pool that is not yet enabled, full or not', () => {
180
+ expect(derivePoolStatus(pool([tranche('Senior', '5000')], false))).toBe(
181
+ 'Coming soon',
182
+ );
183
+ expect(derivePoolStatus(pool([tranche('Senior', '0')], false))).toBe(
184
+ 'Coming soon',
185
+ );
186
+ });
187
+
188
+ it('is "Full" for an enabled pool with no capacity anywhere', () => {
189
+ expect(derivePoolStatus(pool([tranche('Senior', '0')]))).toBe('Full');
190
+ });
191
+
192
+ it('is "Live" for an enabled pool with capacity, or with no tranches yet', () => {
193
+ expect(derivePoolStatus(pool([tranche('Senior', '5000')]))).toBe('Live');
194
+ expect(derivePoolStatus(pool([]))).toBe('Live');
195
+ });
196
+ });
197
+
198
+ describe('trancheApyBounds', () => {
199
+ it('spans the lowest minApy and the highest maxApy across tranches', () => {
200
+ expect(
201
+ trancheApyBounds([rated('0.12', '0.12'), rated('0.185', '0.185')]),
202
+ ).toEqual({ min: 0.12, max: 0.185 });
203
+ });
204
+
205
+ it('carries a single-option strategy as an equal pair', () => {
206
+ expect(trancheApyBounds([rated('0.1', '0.1')])).toEqual({
207
+ min: 0.1,
208
+ max: 0.1,
209
+ });
210
+ });
211
+
212
+ it('skips zero and non-finite rates rather than dragging the range down', () => {
213
+ // A tranche with no fixed-term config must not turn a 10.5% range into
214
+ // 0–10.5%.
215
+ expect(
216
+ trancheApyBounds([rated('0', '0'), rated('0.105', '0.105')]),
217
+ ).toEqual({ min: 0.105, max: 0.105 });
218
+ expect(
219
+ trancheApyBounds([rated('not-a-number', 'x'), rated('0.12', '0.14')]),
220
+ ).toEqual({ min: 0.12, max: 0.14 });
221
+ });
222
+
223
+ it('is null when nothing usable remains', () => {
224
+ expect(trancheApyBounds([])).toBeNull();
225
+ expect(trancheApyBounds([rated('0', '0')])).toBeNull();
226
+ expect(trancheApyBounds([rated('-0.1', '-0.1')])).toBeNull();
227
+ });
228
+
229
+ it('is null when only ONE side is usable — never half a range', () => {
230
+ expect(trancheApyBounds([rated('0.12', '0')])).toBeNull();
231
+ expect(trancheApyBounds([rated('0', '0.12')])).toBeNull();
232
+ });
233
+ });
234
+
235
+ describe('netTrancheApyBounds', () => {
236
+ it('reproduces the spec §2.5 card range: 14–22% gross is 13–20% net', () => {
237
+ const bounds = netTrancheApyBounds([rated('0.14', '0.22')], FEE);
238
+ expect(bounds).not.toBeNull();
239
+ expect(bounds?.min).toBeCloseTo(0.1252, 4);
240
+ expect(bounds?.max).toBeCloseTo(0.196, 4);
241
+ });
242
+
243
+ it('puts each bound through netEffectiveApy and nothing else', () => {
244
+ const bounds = netTrancheApyBounds([rated('0.12', '0.185')], FEE);
245
+ expect(bounds).toEqual({
246
+ min: netEffectiveApy(0.12, FEE),
247
+ max: netEffectiveApy(0.185, FEE),
248
+ });
249
+ });
250
+
251
+ it('returns the gross bounds unchanged at a zero fee', () => {
252
+ expect(netTrancheApyBounds([rated('0.14', '0.22')], 0)).toEqual({
253
+ min: 0.14,
254
+ max: 0.22,
255
+ });
256
+ });
257
+
258
+ it('FAILS CLOSED on an out-of-domain fee — never the gross range', () => {
259
+ // The raw subgraph integer (1000 = 10%) and a negative fee both land
260
+ // here. A caller that does not KNOW the fee must not call at all;
261
+ // substituting 0 would overstate the rate with nothing saying so.
262
+ expect(netTrancheApyBounds([rated('0.14', '0.22')], 1000)).toBeNull();
263
+ expect(netTrancheApyBounds([rated('0.14', '0.22')], -1)).toBeNull();
264
+ expect(netTrancheApyBounds([rated('0.14', '0.22')], NaN)).toBeNull();
265
+ });
266
+
267
+ it('is null when a 100% fee leaves the lender nothing', () => {
268
+ expect(netTrancheApyBounds([rated('0.14', '0.22')], 100)).toBeNull();
269
+ });
270
+
271
+ it('is null whenever the gross range is', () => {
272
+ expect(netTrancheApyBounds([], FEE)).toBeNull();
273
+ expect(netTrancheApyBounds([rated('0', '0')], FEE)).toBeNull();
274
+ });
275
+
276
+ it('never inverts the range it was given', () => {
277
+ fc.assert(
278
+ fc.property(
279
+ fc.double({ min: 0.0001, max: 0.99, noNaN: true }),
280
+ fc.double({ min: 0.0001, max: 0.99, noNaN: true }),
281
+ fc.double({ min: 0, max: 99, noNaN: true }),
282
+ (a, b, feePercent) => {
283
+ const bounds = netTrancheApyBounds(
284
+ [
285
+ rated(
286
+ String(Math.min(a, b)),
287
+ String(Math.max(a, b)),
288
+ ),
289
+ ],
290
+ feePercent,
291
+ );
292
+ if (!bounds) return;
293
+ expect(bounds.min).toBeLessThanOrEqual(bounds.max);
294
+ expect(bounds.min).toBeGreaterThan(0);
295
+ },
296
+ ),
297
+ { numRuns: 2000 },
298
+ );
299
+ });
300
+ });