@kasufinance/kasu-sdk 2.4.2 → 2.5.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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -253
  3. package/dist/bundle.cjs.js +4775 -1794
  4. package/dist/bundle.esm.js +4764 -1816
  5. package/dist/domain/deposit-bounds.d.ts +62 -0
  6. package/dist/domain/deposit-bounds.js +84 -0
  7. package/dist/domain/deposit-bounds.js.map +1 -0
  8. package/dist/domain/deposit-bounds.test.js +135 -0
  9. package/dist/domain/deposit-bounds.test.js.map +1 -0
  10. package/dist/domain/index.d.ts +26 -0
  11. package/dist/domain/index.js +23 -0
  12. package/dist/domain/index.js.map +1 -0
  13. package/dist/domain/partners.d.ts +34 -0
  14. package/dist/domain/partners.js +43 -0
  15. package/dist/domain/partners.js.map +1 -0
  16. package/dist/domain/partners.test.d.ts +1 -0
  17. package/dist/domain/partners.test.js +53 -0
  18. package/dist/domain/partners.test.js.map +1 -0
  19. package/dist/domain/pools.d.ts +70 -0
  20. package/dist/domain/pools.js +134 -0
  21. package/dist/domain/pools.js.map +1 -0
  22. package/dist/domain/pools.test.d.ts +1 -0
  23. package/dist/domain/pools.test.js +184 -0
  24. package/dist/domain/pools.test.js.map +1 -0
  25. package/dist/domain/rates.d.ts +83 -0
  26. package/dist/domain/rates.js +120 -0
  27. package/dist/domain/rates.js.map +1 -0
  28. package/dist/domain/rates.test.d.ts +1 -0
  29. package/dist/domain/rates.test.js +181 -0
  30. package/dist/domain/rates.test.js.map +1 -0
  31. package/dist/domain/tranche-display-name.d.ts +17 -0
  32. package/dist/domain/tranche-display-name.js +30 -0
  33. package/dist/domain/tranche-display-name.js.map +1 -0
  34. package/dist/domain/tranche-display-name.test.d.ts +1 -0
  35. package/dist/domain/tranche-display-name.test.js +58 -0
  36. package/dist/domain/tranche-display-name.test.js.map +1 -0
  37. package/dist/domain/tranches.d.ts +89 -0
  38. package/dist/domain/tranches.js +150 -0
  39. package/dist/domain/tranches.js.map +1 -0
  40. package/dist/domain/tranches.test.d.ts +1 -0
  41. package/dist/domain/tranches.test.js +206 -0
  42. package/dist/domain/tranches.test.js.map +1 -0
  43. package/dist/facade/chain-configs.js +54 -0
  44. package/dist/facade/chain-configs.js.map +1 -1
  45. package/dist/facade/config.test.d.ts +1 -0
  46. package/dist/facade/config.test.js +216 -0
  47. package/dist/facade/config.test.js.map +1 -0
  48. package/dist/facade/deposits.d.ts +22 -1
  49. package/dist/facade/deposits.js +24 -1
  50. package/dist/facade/deposits.js.map +1 -1
  51. package/dist/facade/facade.test.d.ts +1 -0
  52. package/dist/{tests → facade}/facade.test.js +10 -5
  53. package/dist/facade/facade.test.js.map +1 -0
  54. package/dist/facade/index.d.ts +2 -1
  55. package/dist/facade/index.js +2 -0
  56. package/dist/facade/index.js.map +1 -1
  57. package/dist/facade/kasu.d.ts +49 -8
  58. package/dist/facade/kasu.js +117 -13
  59. package/dist/facade/kasu.js.map +1 -1
  60. package/dist/facade/strategies.d.ts +25 -0
  61. package/dist/facade/strategies.js +36 -0
  62. package/dist/facade/strategies.js.map +1 -1
  63. package/dist/facade/types.d.ts +57 -2
  64. package/dist/facade/unused-pool-ids.d.ts +31 -0
  65. package/dist/facade/unused-pool-ids.js +57 -0
  66. package/dist/facade/unused-pool-ids.js.map +1 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +5 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/sdk-config.d.ts +11 -0
  71. package/dist/sdk-config.js +8 -1
  72. package/dist/sdk-config.js.map +1 -1
  73. package/dist/services/DataService/data-service.d.ts +5 -0
  74. package/dist/services/DataService/data-service.js +7 -2
  75. package/dist/services/DataService/data-service.js.map +1 -1
  76. package/dist/services/Locking/calculate-apy.test.d.ts +1 -0
  77. package/dist/services/Locking/calculate-apy.test.js +41 -0
  78. package/dist/services/Locking/calculate-apy.test.js.map +1 -0
  79. package/dist/services/Locking/locking.d.ts +14 -0
  80. package/dist/services/Locking/locking.js +16 -2
  81. package/dist/services/Locking/locking.js.map +1 -1
  82. package/dist/tests/sample.test.js +46 -25
  83. package/dist/tests/sample.test.js.map +1 -1
  84. package/package.json +43 -12
  85. package/src/domain/deposit-bounds.test.ts +200 -0
  86. package/src/domain/deposit-bounds.ts +118 -0
  87. package/src/domain/index.ts +70 -0
  88. package/src/domain/partners.test.ts +83 -0
  89. package/src/domain/partners.ts +66 -0
  90. package/src/domain/pools.test.ts +260 -0
  91. package/src/domain/pools.ts +150 -0
  92. package/src/domain/rates.test.ts +254 -0
  93. package/src/domain/rates.ts +120 -0
  94. package/src/domain/tranche-display-name.test.ts +96 -0
  95. package/src/domain/tranche-display-name.ts +34 -0
  96. package/src/domain/tranches.test.ts +300 -0
  97. package/src/domain/tranches.ts +177 -0
  98. package/src/facade/chain-configs.ts +55 -0
  99. package/src/facade/config.test.ts +265 -0
  100. package/src/facade/deposits.ts +25 -0
  101. package/src/{tests → facade}/facade.test.ts +10 -5
  102. package/src/facade/index.ts +4 -0
  103. package/src/facade/kasu.ts +155 -12
  104. package/src/facade/strategies.ts +36 -0
  105. package/src/facade/types.ts +60 -2
  106. package/src/facade/unused-pool-ids.ts +59 -0
  107. package/src/index.ts +6 -0
  108. package/src/sdk-config.ts +19 -1
  109. package/src/services/DataService/data-service.ts +7 -2
  110. package/src/services/Locking/calculate-apy.test.ts +52 -0
  111. package/src/services/Locking/locking.ts +16 -2
  112. package/src/tests/sample.test.ts +54 -33
  113. package/.eslintignore +0 -3
  114. package/.eslintrc.json +0 -90
  115. package/.github/workflows/ci.yml +0 -39
  116. package/.github/workflows/gitleaks.yml +0 -22
  117. package/.github/workflows/main.yml +0 -41
  118. package/.gitleaksignore +0 -19
  119. package/.prettierignore +0 -3
  120. package/.prettierrc +0 -28
  121. package/CLAUDE.md +0 -410
  122. package/copy-abi.ps1 +0 -15
  123. package/dist/tests/facade.test.js.map +0 -1
  124. package/index.ts +0 -2
  125. package/jest-config.ts +0 -9
  126. package/jest.config.js +0 -8
  127. package/rollup.config.js +0 -45
  128. package/tsconfig.json +0 -27
  129. /package/dist/{tests/facade.test.d.ts → domain/deposit-bounds.test.d.ts} +0 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Epochs in a year — the single source of truth for every rate conversion in
3
+ * the SDK and in every consumer that undoes one.
4
+ *
5
+ * The subgraph carries a per-epoch `interestRate`; `epochRateToApy` compounds
6
+ * it into an APY and `apyToEpochRate` inverts exactly that. A second copy of
7
+ * this number anywhere would recover a different weekly rate and every derived
8
+ * figure — displayed APYs, net rates, projected interest — would be wrong, so
9
+ * `DataService.calculateApyForTranche` and `KSULocking.calculateApy` both read
10
+ * it from here rather than declaring their own.
11
+ */
12
+ export const EPOCHS_IN_YEAR = 52.17857;
13
+
14
+ /**
15
+ * A per-epoch interest rate compounded into an annual rate.
16
+ *
17
+ * ```
18
+ * apy = (1 + r)^E − 1
19
+ * ```
20
+ *
21
+ * This is the exact expression `DataService.calculateApyForTranche` has always
22
+ * used, kept in its literal `**` form rather than rewritten through
23
+ * `expm1`/`log1p`: it is the SDK's OWN definition of `tranche.apy`, so its
24
+ * float behaviour is part of the contract every consumer already calibrated
25
+ * against. `apyToEpochRate` is the one that must be numerically careful,
26
+ * because it runs on the result.
27
+ *
28
+ * @param epochRate the per-epoch rate as a 0..1 fraction (e.g. `0.003`).
29
+ * @returns the compounded annual rate as a 0..1 fraction.
30
+ */
31
+ export function epochRateToApy(epochRate: number): number {
32
+ return (1 + epochRate) ** EPOCHS_IN_YEAR - 1;
33
+ }
34
+
35
+ /**
36
+ * The inverse of `epochRateToApy` — the per-epoch rate an APY was compounded
37
+ * from.
38
+ *
39
+ * `expm1`/`log1p` rather than the literal `(1 + apy) ** (1 / E) - 1`.
40
+ * Algebraically identical, but the direct form computes a double just above 1
41
+ * and subtracts 1 from it, discarding the low bits of a result that is itself
42
+ * ~1e-3 for a realistic rate. These two never form the intermediate near-1
43
+ * value.
44
+ *
45
+ * @param apy the compounded annual rate as a 0..1 fraction.
46
+ * @returns the per-epoch rate as a 0..1 fraction.
47
+ */
48
+ export function apyToEpochRate(apy: number): number {
49
+ return Math.expm1(Math.log1p(apy) / EPOCHS_IN_YEAR);
50
+ }
51
+
52
+ /**
53
+ * The NET Effective Interest Rate — the compounded annual rate a lender
54
+ * actually earns, after the platform performance fee.
55
+ *
56
+ * ```
57
+ * r = (1 + grossApy)^(1/E) − 1 // the gross per-epoch rate
58
+ * net = (1 + r · (1 − feePercent/100))^E − 1 // fee taken each epoch, then compounded
59
+ * ```
60
+ *
61
+ * `LendingPool._applyTrancheInterest` mints the lender
62
+ * `interest × (1 − performanceFee)` at every epoch close, and it is that NET
63
+ * amount which capitalises and earns interest in the following epoch. So the
64
+ * fee is applied per epoch and the result is re-compounded — not deducted from
65
+ * the annual figure.
66
+ *
67
+ * ⚠️ UNITS — the one thing that must not be got wrong.
68
+ * `feePercent` is a PERCENTAGE IN 0..100, **not** a 0..1 fraction.
69
+ * `DataService.getPerformanceFee()` (and `StrategiesFacade.getPerformanceFeePercent()`)
70
+ * returns the subgraph's `integerToPercentage2(1000)` = `10`, meaning **ten
71
+ * percent**. It is NOT `0.10`. Treating it as a fraction computes
72
+ * `r · (1 − 10)` = `−9r` and yields a nonsense negative rate that would render
73
+ * as a plausible-looking `-…%`, which is why `feePercent > 100` and
74
+ * `feePercent < 0` both return `NaN` and why the parameter is named
75
+ * `feePercent` and never `fee`.
76
+ *
77
+ * This returns a NUMBER and nothing else. Rendering it — the 2dp convention,
78
+ * the ` p.a.` suffix, and the fail-closed em-dash for a `NaN` or non-positive
79
+ * result — is the consumer's job: kasu-ui does it in
80
+ * `src/features/lending/lib/interest-rate.ts` (`formatEffectiveRate`,
81
+ * `RATE_UNAVAILABLE`), which now wraps this function instead of restating it.
82
+ *
83
+ * @param grossApy compounded GROSS APY as a 0..1 fraction (e.g. `0.22` for
84
+ * 22%), exactly as the SDK carries it on `tranche.apy` / `tranche.maxApy`.
85
+ * @param feePercent the chain's performance fee as a percentage, `0..100`
86
+ * (e.g. `10` for the live 10% fee).
87
+ * @returns the net compounded annual rate as a 0..1 fraction, or `NaN` when
88
+ * either input is outside its domain. Callers render `NaN` as "—".
89
+ */
90
+ export function netEffectiveApy(grossApy: number, feePercent: number): number {
91
+ if (!Number.isFinite(grossApy) || grossApy < 0) return NaN;
92
+ if (!Number.isFinite(feePercent) || feePercent < 0 || feePercent > 100) {
93
+ return NaN;
94
+ }
95
+ // The lender's share of each epoch's interest. Short-circuiting on `=== 1`
96
+ // (rather than on `feePercent === 0`) also covers a fee small enough to
97
+ // underflow the multiplier: if it cannot change the epoch rate, the net
98
+ // rate IS the gross rate, and it must be returned unchanged rather than
99
+ // round-tripped through the de-compound/re-compound pair below — which is
100
+ // exact in algebra but not in floating point (0.22 comes back as
101
+ // 0.22000000000000663 in the naive form). If the fee is ever set to zero
102
+ // on-chain, the displayed rate must equal the SDK's APY exactly.
103
+ const lenderShare = 1 - feePercent / 100;
104
+ if (lenderShare === 1) return grossApy;
105
+ // `expm1`/`log1p` rather than the literal `(1 + x) ** n - 1`. Algebraically
106
+ // identical, but the direct form computes `(1 + grossApy) ** (1 / E) - 1`
107
+ // by subtracting 1 from a double just above 1, discarding the low bits of a
108
+ // result that is itself ~1e-8 for a realistic rate. The ~1e-8 relative
109
+ // error that survives is enough to push the net rate ABOVE the gross one;
110
+ // these two never form the intermediate near-1 value.
111
+ const grossEpochRate = apyToEpochRate(grossApy);
112
+ const netEpochRate = grossEpochRate * lenderShare;
113
+ const net = Math.expm1(EPOCHS_IN_YEAR * Math.log1p(netEpochRate));
114
+ // `net ≤ grossApy` for every `lenderShare ≤ 1` is a theorem, but at the
115
+ // last ULP it is not a float identity — a fee of 1e-14 percent leaves a
116
+ // `lenderShare` one ULP below 1 and the round trip can land one ULP above
117
+ // `grossApy`. Clamping enforces the proven bound rather than letting a
118
+ // rounding artefact put a net rate above the gross rate it came from.
119
+ return Math.min(net, grossApy);
120
+ }
@@ -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
+ });