@kasufinance/kasu-sdk 2.6.0 → 2.7.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 (122) hide show
  1. package/README.md +128 -3
  2. package/dist/bundle.cjs.js +1119 -254
  3. package/dist/bundle.esm.js +1109 -255
  4. package/dist/domain/index.d.ts +3 -2
  5. package/dist/domain/index.js +2 -2
  6. package/dist/domain/index.js.map +1 -1
  7. package/dist/domain/requests.d.ts +42 -4
  8. package/dist/domain/requests.js +20 -4
  9. package/dist/domain/requests.js.map +1 -1
  10. package/dist/domain/wallet-errors.d.ts +63 -8
  11. package/dist/domain/wallet-errors.js +115 -16
  12. package/dist/domain/wallet-errors.js.map +1 -1
  13. package/dist/facade/deposits.js +2 -1
  14. package/dist/facade/deposits.js.map +1 -1
  15. package/dist/facade/flows.d.ts +95 -0
  16. package/dist/facade/flows.js +116 -0
  17. package/dist/facade/flows.js.map +1 -0
  18. package/dist/facade/index.d.ts +3 -0
  19. package/dist/facade/index.js +3 -0
  20. package/dist/facade/index.js.map +1 -1
  21. package/dist/facade/kasu.d.ts +6 -2
  22. package/dist/facade/kasu.js +28 -3
  23. package/dist/facade/kasu.js.map +1 -1
  24. package/dist/facade/read-only.d.ts +12 -0
  25. package/dist/facade/read-only.js +13 -0
  26. package/dist/facade/read-only.js.map +1 -0
  27. package/dist/flows/deposit-flow.d.ts +301 -0
  28. package/dist/flows/deposit-flow.js +358 -0
  29. package/dist/flows/deposit-flow.js.map +1 -0
  30. package/dist/flows/flow.d.ts +72 -0
  31. package/dist/flows/flow.js +110 -0
  32. package/dist/flows/flow.js.map +1 -0
  33. package/dist/flows/index.d.ts +19 -0
  34. package/dist/flows/index.js +17 -0
  35. package/dist/flows/index.js.map +1 -0
  36. package/dist/flows/observable.d.ts +59 -0
  37. package/dist/flows/observable.js +81 -0
  38. package/dist/flows/observable.js.map +1 -0
  39. package/dist/flows/withdraw-flow.d.ts +99 -0
  40. package/dist/flows/withdraw-flow.js +83 -0
  41. package/dist/flows/withdraw-flow.js.map +1 -0
  42. package/dist/index.d.ts +1 -0
  43. package/dist/index.js +6 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/services/DataService/data-service.js +0 -1
  46. package/dist/services/DataService/data-service.js.map +1 -1
  47. package/package.json +8 -2
  48. package/src/domain/index.ts +7 -1
  49. package/src/domain/requests.ts +57 -4
  50. package/src/domain/wallet-errors.ts +135 -18
  51. package/src/facade/deposits.ts +2 -3
  52. package/src/facade/flows.ts +172 -0
  53. package/src/facade/index.ts +8 -0
  54. package/src/facade/kasu.ts +43 -3
  55. package/src/facade/read-only.ts +13 -0
  56. package/src/flows/deposit-flow.ts +775 -0
  57. package/src/flows/flow.ts +108 -0
  58. package/src/flows/index.ts +45 -0
  59. package/src/flows/observable.ts +97 -0
  60. package/src/flows/withdraw-flow.ts +210 -0
  61. package/src/index.ts +7 -0
  62. package/src/services/DataService/data-service.ts +0 -1
  63. package/dist/domain/au-minimum.test.d.ts +0 -1
  64. package/dist/domain/au-minimum.test.js +0 -202
  65. package/dist/domain/au-minimum.test.js.map +0 -1
  66. package/dist/domain/deposit-bounds.test.d.ts +0 -1
  67. package/dist/domain/deposit-bounds.test.js +0 -135
  68. package/dist/domain/deposit-bounds.test.js.map +0 -1
  69. package/dist/domain/loan-contract.test.d.ts +0 -1
  70. package/dist/domain/loan-contract.test.js +0 -255
  71. package/dist/domain/loan-contract.test.js.map +0 -1
  72. package/dist/domain/partners.test.d.ts +0 -1
  73. package/dist/domain/partners.test.js +0 -53
  74. package/dist/domain/partners.test.js.map +0 -1
  75. package/dist/domain/pools.test.d.ts +0 -1
  76. package/dist/domain/pools.test.js +0 -184
  77. package/dist/domain/pools.test.js.map +0 -1
  78. package/dist/domain/rates.test.d.ts +0 -1
  79. package/dist/domain/rates.test.js +0 -181
  80. package/dist/domain/rates.test.js.map +0 -1
  81. package/dist/domain/requests.test.d.ts +0 -1
  82. package/dist/domain/requests.test.js +0 -470
  83. package/dist/domain/requests.test.js.map +0 -1
  84. package/dist/domain/settlement.test.d.ts +0 -1
  85. package/dist/domain/settlement.test.js +0 -152
  86. package/dist/domain/settlement.test.js.map +0 -1
  87. package/dist/domain/tranche-display-name.test.d.ts +0 -1
  88. package/dist/domain/tranche-display-name.test.js +0 -58
  89. package/dist/domain/tranche-display-name.test.js.map +0 -1
  90. package/dist/domain/tranches.test.d.ts +0 -1
  91. package/dist/domain/tranches.test.js +0 -206
  92. package/dist/domain/tranches.test.js.map +0 -1
  93. package/dist/domain/wallet-errors.test.d.ts +0 -1
  94. package/dist/domain/wallet-errors.test.js +0 -71
  95. package/dist/domain/wallet-errors.test.js.map +0 -1
  96. package/dist/facade/config.test.d.ts +0 -1
  97. package/dist/facade/config.test.js +0 -216
  98. package/dist/facade/config.test.js.map +0 -1
  99. package/dist/facade/facade.test.d.ts +0 -1
  100. package/dist/facade/facade.test.js +0 -278
  101. package/dist/facade/facade.test.js.map +0 -1
  102. package/dist/services/Locking/calculate-apy.test.d.ts +0 -1
  103. package/dist/services/Locking/calculate-apy.test.js +0 -41
  104. package/dist/services/Locking/calculate-apy.test.js.map +0 -1
  105. package/dist/tests/sample.test.d.ts +0 -1
  106. package/dist/tests/sample.test.js +0 -59
  107. package/dist/tests/sample.test.js.map +0 -1
  108. package/src/domain/au-minimum.test.ts +0 -371
  109. package/src/domain/deposit-bounds.test.ts +0 -200
  110. package/src/domain/loan-contract.test.ts +0 -343
  111. package/src/domain/partners.test.ts +0 -83
  112. package/src/domain/pools.test.ts +0 -260
  113. package/src/domain/rates.test.ts +0 -254
  114. package/src/domain/requests.test.ts +0 -653
  115. package/src/domain/settlement.test.ts +0 -198
  116. package/src/domain/tranche-display-name.test.ts +0 -96
  117. package/src/domain/tranches.test.ts +0 -300
  118. package/src/domain/wallet-errors.test.ts +0 -100
  119. package/src/facade/config.test.ts +0 -265
  120. package/src/facade/facade.test.ts +0 -374
  121. package/src/services/Locking/calculate-apy.test.ts +0 -52
  122. package/src/tests/sample.test.ts +0 -63
@@ -1,198 +0,0 @@
1
- import {
2
- CLEARING_WINDOW_SECONDS,
3
- computeSettlementWindow,
4
- deriveCycleDates,
5
- nextCycleBoundary,
6
- } from './settlement';
7
-
8
- /**
9
- * Ported from kasu-ui `src/features/portfolio/lib/settlement-window.test.ts`
10
- * and the non-format half of
11
- * `src/features/lending/lib/cycle-dates.test.ts`. The `formatCycleDate` /
12
- * `formatCycleCloseUtc` cases stay there — they assert printed words.
13
- */
14
-
15
- const HOUR = 60 * 60;
16
- const DAY = 24 * HOUR;
17
- const CLEARING = 48 * HOUR;
18
- const WEEK = 7 * DAY;
19
-
20
- describe('computeSettlementWindow', () => {
21
- it('returns "unknown" when the epoch boundary is missing', () => {
22
- expect(
23
- computeSettlementWindow({
24
- nowSeconds: 1_000_000,
25
- nextEpochStart: 0,
26
- }),
27
- ).toEqual({ phase: 'unknown' });
28
- });
29
-
30
- it('returns "unknown" when the epoch boundary is stale (already elapsed)', () => {
31
- expect(
32
- computeSettlementWindow({
33
- nowSeconds: 1_000_000,
34
- nextEpochStart: 999_000,
35
- }),
36
- ).toEqual({ phase: 'unknown' });
37
- });
38
-
39
- it('counts down to the clearing window when more than 48h from epoch end', () => {
40
- const epochEnd = 1_700_000_000; // Thu 06:00 UTC
41
- const now = epochEnd - 3 * DAY; // 24h before the window opens
42
- const result = computeSettlementWindow({
43
- nowSeconds: now,
44
- nextEpochStart: epochEnd,
45
- });
46
-
47
- expect(result.phase).toBe('awaiting');
48
- if (result.phase !== 'awaiting') return;
49
- expect(result.nextClearingStart).toBe(epochEnd - CLEARING); // Tue 06:00
50
- expect(result.secondsUntilClearing).toBe(DAY);
51
- });
52
-
53
- it('reports "clearing" only inside the 48h window before epoch end', () => {
54
- const epochEnd = 1_700_000_000;
55
- const now = epochEnd - 24 * HOUR; // 24h into the window
56
- const result = computeSettlementWindow({
57
- nowSeconds: now,
58
- nextEpochStart: epochEnd,
59
- });
60
-
61
- expect(result.phase).toBe('clearing');
62
- if (result.phase !== 'clearing') return;
63
- expect(result.epochEnd).toBe(epochEnd);
64
- expect(result.secondsUntilEpochEnd).toBe(24 * HOUR);
65
- });
66
-
67
- it('flips awaiting → clearing exactly at the window boundary (T-48h)', () => {
68
- const epochEnd = 1_700_000_000;
69
- expect(
70
- computeSettlementWindow({
71
- nowSeconds: epochEnd - CLEARING - 1,
72
- nextEpochStart: epochEnd,
73
- }).phase,
74
- ).toBe('awaiting');
75
- expect(
76
- computeSettlementWindow({
77
- nowSeconds: epochEnd - CLEARING,
78
- nextEpochStart: epochEnd,
79
- }).phase,
80
- ).toBe('clearing');
81
- });
82
-
83
- it('respects a non-default clearing window length', () => {
84
- const epochEnd = 1_700_000_000;
85
- const now = epochEnd - 36 * HOUR;
86
- // 36h out is "clearing" under the default 48h window but "awaiting"
87
- // under a 24h one.
88
- expect(
89
- computeSettlementWindow({
90
- nowSeconds: now,
91
- nextEpochStart: epochEnd,
92
- }).phase,
93
- ).toBe('clearing');
94
- expect(
95
- computeSettlementWindow({
96
- nowSeconds: now,
97
- nextEpochStart: epochEnd,
98
- clearingWindowSeconds: 24 * HOUR,
99
- }).phase,
100
- ).toBe('awaiting');
101
- });
102
- });
103
-
104
- describe('nextCycleBoundary', () => {
105
- const epochEnd = 1_700_000_000;
106
- const clearingStart = epochEnd - CLEARING;
107
-
108
- it('returns the cycle close while the window is still ahead', () => {
109
- expect(nextCycleBoundary(epochEnd, epochEnd - 3 * DAY)).toBe(
110
- clearingStart,
111
- );
112
- });
113
-
114
- it('returns the epoch end once inside the clearing window', () => {
115
- expect(nextCycleBoundary(epochEnd, clearingStart + 1)).toBe(epochEnd);
116
- });
117
-
118
- it('hands over from close to epoch end exactly at the boundary instant', () => {
119
- // At T-48h the close has just been reached, so the next thing to wait
120
- // for is the epoch end — not the instant we are standing on.
121
- expect(nextCycleBoundary(epochEnd, clearingStart - 1)).toBe(
122
- clearingStart,
123
- );
124
- expect(nextCycleBoundary(epochEnd, clearingStart)).toBe(epochEnd);
125
- });
126
-
127
- it('returns undefined when there is nothing left to wait for', () => {
128
- expect(nextCycleBoundary(epochEnd, epochEnd)).toBeUndefined(); // elapsed
129
- expect(nextCycleBoundary(epochEnd, epochEnd + DAY)).toBeUndefined(); // stale
130
- expect(nextCycleBoundary(undefined, epochEnd - DAY)).toBeUndefined(); // not loaded
131
- expect(nextCycleBoundary(0, epochEnd - DAY)).toBeUndefined(); // sentinel zero
132
- });
133
-
134
- it('agrees with computeSettlementWindow about where the window opens', () => {
135
- // One number, two consumers: the phase machine and the timer helper
136
- // must never disagree about when the cycle turns.
137
- expect(CLEARING_WINDOW_SECONDS).toBe(CLEARING);
138
- const justInside = nextCycleBoundary(epochEnd, clearingStart);
139
- expect(
140
- computeSettlementWindow({
141
- nowSeconds: clearingStart,
142
- nextEpochStart: epochEnd,
143
- }).phase,
144
- ).toBe('clearing');
145
- expect(justInside).toBe(epochEnd);
146
- });
147
-
148
- it('respects a non-default clearing window length', () => {
149
- const now = epochEnd - 36 * HOUR;
150
- // 36h out: inside a 48h window (next stop is the epoch end), still
151
- // ahead of a 24h one (next stop is that window opening).
152
- expect(nextCycleBoundary(epochEnd, now)).toBe(epochEnd);
153
- expect(nextCycleBoundary(epochEnd, now, 24 * HOUR)).toBe(
154
- epochEnd - 24 * HOUR,
155
- );
156
- });
157
- });
158
-
159
- describe('deriveCycleDates', () => {
160
- // Fixture: epoch end = Thu 6 Aug 2026, 06:00 UTC.
161
- const EPOCH_END = Date.UTC(2026, 7, 6, 6, 0, 0) / 1000; // 1785996000
162
- const CLOSE = EPOCH_END - CLEARING; // Tue 4 Aug 06:00 UTC
163
-
164
- it('returns close = epochEnd − 48h and outcome = epochEnd before the window opens', () => {
165
- const now = Date.UTC(2026, 7, 1, 12, 0, 0) / 1000; // Sat 1 Aug
166
- expect(deriveCycleDates(EPOCH_END, now)).toEqual({
167
- close: CLOSE,
168
- outcome: EPOCH_END,
169
- });
170
- });
171
-
172
- it('advances to the next weekly cycle when the request lands inside the clearing window', () => {
173
- // now is Wed 5 Aug 00:00 — inside [close, epochEnd): this cycle has
174
- // already closed, so the request queues for next week.
175
- const now = Date.UTC(2026, 7, 5, 0, 0, 0) / 1000;
176
- expect(deriveCycleDates(EPOCH_END, now)).toEqual({
177
- close: CLOSE + WEEK,
178
- outcome: EPOCH_END + WEEK,
179
- });
180
- });
181
-
182
- it('returns null when the epoch boundary is missing', () => {
183
- expect(deriveCycleDates(undefined, EPOCH_END - WEEK)).toBeNull();
184
- expect(deriveCycleDates(0, EPOCH_END - WEEK)).toBeNull();
185
- });
186
-
187
- it('returns null when the epoch boundary is stale (already elapsed)', () => {
188
- expect(deriveCycleDates(EPOCH_END, EPOCH_END + 60)).toBeNull();
189
- });
190
-
191
- it('runs on the same window constant as the phase machine', () => {
192
- const now = Date.UTC(2026, 7, 1, 12, 0, 0) / 1000;
193
- const dates = deriveCycleDates(EPOCH_END, now);
194
- expect(dates?.outcome).toBe(
195
- (dates?.close ?? 0) + CLEARING_WINDOW_SECONDS,
196
- );
197
- });
198
- });
@@ -1,96 +0,0 @@
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
- });
@@ -1,300 +0,0 @@
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
- });
@@ -1,100 +0,0 @@
1
- import { isUnpredictableGas, isUserRejected } from './wallet-errors';
2
-
3
- /**
4
- * `isUserRejected` cases ported from kasu-ui
5
- * `src/lib/web3/is-user-rejected.test.ts`. `isUnpredictableGas` had no test in
6
- * kasu-mobile; one is written here.
7
- */
8
-
9
- describe('isUserRejected', () => {
10
- it('detects EIP-1193 numeric code 4001', () => {
11
- expect(isUserRejected({ code: 4001, message: 'whatever' })).toBe(true);
12
- });
13
-
14
- it('detects ACTION_REJECTED string code (ethers v5)', () => {
15
- expect(
16
- isUserRejected({ code: 'ACTION_REJECTED', message: 'rejected' }),
17
- ).toBe(true);
18
- });
19
-
20
- it('detects "User rejected" message string', () => {
21
- expect(isUserRejected(new Error('User rejected the request'))).toBe(
22
- true,
23
- );
24
- });
25
-
26
- it('detects "User denied" message string', () => {
27
- expect(
28
- isUserRejected(new Error('User denied transaction signature')),
29
- ).toBe(true);
30
- });
31
-
32
- it('detects the ethers ACTION_REJECTED marker in a message', () => {
33
- expect(
34
- isUserRejected(new Error('transaction failed: ACTION_REJECTED')),
35
- ).toBe(true);
36
- });
37
-
38
- it('is case-insensitive', () => {
39
- expect(isUserRejected(new Error('user REJECTED the request'))).toBe(
40
- true,
41
- );
42
- });
43
-
44
- it('returns false for unrelated errors', () => {
45
- expect(isUserRejected(new Error('Network error'))).toBe(false);
46
- expect(isUserRejected(new Error('Insufficient funds'))).toBe(false);
47
- });
48
-
49
- it('returns false for null / undefined', () => {
50
- expect(isUserRejected(null)).toBe(false);
51
- expect(isUserRejected(undefined)).toBe(false);
52
- });
53
-
54
- it('handles non-Error throwables', () => {
55
- expect(isUserRejected('user rejected')).toBe(true);
56
- expect(isUserRejected('something else')).toBe(false);
57
- });
58
-
59
- it('is not fooled by a numeric code that only looks like 4001', () => {
60
- expect(isUserRejected({ code: '4001' })).toBe(false);
61
- expect(isUserRejected({ code: 4002 })).toBe(false);
62
- });
63
- });
64
-
65
- describe('isUnpredictableGas', () => {
66
- it('detects the ethers v5 gas-estimation revert code', () => {
67
- expect(isUnpredictableGas({ code: 'UNPREDICTABLE_GAS_LIMIT' })).toBe(
68
- true,
69
- );
70
- });
71
-
72
- it('does not fire on a user rejection', () => {
73
- expect(isUnpredictableGas({ code: 'ACTION_REJECTED' })).toBe(false);
74
- expect(isUnpredictableGas({ code: 4001 })).toBe(false);
75
- });
76
-
77
- it('does not fire on the code appearing only in a message', () => {
78
- // The code is the signal; a message mentioning it is not one, or a
79
- // logged error string would be mistaken for a revert.
80
- expect(
81
- isUnpredictableGas(new Error('UNPREDICTABLE_GAS_LIMIT')),
82
- ).toBe(false);
83
- expect(isUnpredictableGas('UNPREDICTABLE_GAS_LIMIT')).toBe(false);
84
- });
85
-
86
- it('returns false for null / undefined and for unrelated errors', () => {
87
- expect(isUnpredictableGas(null)).toBe(false);
88
- expect(isUnpredictableGas(undefined)).toBe(false);
89
- expect(isUnpredictableGas(new Error('Network error'))).toBe(false);
90
- });
91
-
92
- it('reads the code off an ethers error object', () => {
93
- const err = Object.assign(new Error('cannot estimate gas'), {
94
- code: 'UNPREDICTABLE_GAS_LIMIT',
95
- reason: 'execution reverted: ERC20: insufficient allowance',
96
- });
97
- expect(isUnpredictableGas(err)).toBe(true);
98
- expect(isUserRejected(err)).toBe(false);
99
- });
100
- });