@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.
- package/LICENSE +21 -0
- package/README.md +189 -253
- package/dist/bundle.cjs.js +4775 -1794
- package/dist/bundle.esm.js +4764 -1816
- package/dist/domain/deposit-bounds.d.ts +62 -0
- package/dist/domain/deposit-bounds.js +84 -0
- package/dist/domain/deposit-bounds.js.map +1 -0
- package/dist/domain/deposit-bounds.test.js +135 -0
- package/dist/domain/deposit-bounds.test.js.map +1 -0
- package/dist/domain/index.d.ts +26 -0
- package/dist/domain/index.js +23 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/partners.d.ts +34 -0
- package/dist/domain/partners.js +43 -0
- package/dist/domain/partners.js.map +1 -0
- package/dist/domain/partners.test.d.ts +1 -0
- package/dist/domain/partners.test.js +53 -0
- package/dist/domain/partners.test.js.map +1 -0
- package/dist/domain/pools.d.ts +70 -0
- package/dist/domain/pools.js +134 -0
- package/dist/domain/pools.js.map +1 -0
- package/dist/domain/pools.test.d.ts +1 -0
- package/dist/domain/pools.test.js +184 -0
- package/dist/domain/pools.test.js.map +1 -0
- package/dist/domain/rates.d.ts +83 -0
- package/dist/domain/rates.js +120 -0
- package/dist/domain/rates.js.map +1 -0
- package/dist/domain/rates.test.d.ts +1 -0
- package/dist/domain/rates.test.js +181 -0
- package/dist/domain/rates.test.js.map +1 -0
- package/dist/domain/tranche-display-name.d.ts +17 -0
- package/dist/domain/tranche-display-name.js +30 -0
- package/dist/domain/tranche-display-name.js.map +1 -0
- package/dist/domain/tranche-display-name.test.d.ts +1 -0
- package/dist/domain/tranche-display-name.test.js +58 -0
- package/dist/domain/tranche-display-name.test.js.map +1 -0
- package/dist/domain/tranches.d.ts +89 -0
- package/dist/domain/tranches.js +150 -0
- package/dist/domain/tranches.js.map +1 -0
- package/dist/domain/tranches.test.d.ts +1 -0
- package/dist/domain/tranches.test.js +206 -0
- package/dist/domain/tranches.test.js.map +1 -0
- package/dist/facade/chain-configs.js +54 -0
- package/dist/facade/chain-configs.js.map +1 -1
- package/dist/facade/config.test.d.ts +1 -0
- package/dist/facade/config.test.js +216 -0
- package/dist/facade/config.test.js.map +1 -0
- package/dist/facade/deposits.d.ts +22 -1
- package/dist/facade/deposits.js +24 -1
- package/dist/facade/deposits.js.map +1 -1
- package/dist/facade/facade.test.d.ts +1 -0
- package/dist/{tests → facade}/facade.test.js +10 -5
- package/dist/facade/facade.test.js.map +1 -0
- package/dist/facade/index.d.ts +2 -1
- package/dist/facade/index.js +2 -0
- package/dist/facade/index.js.map +1 -1
- package/dist/facade/kasu.d.ts +49 -8
- package/dist/facade/kasu.js +117 -13
- package/dist/facade/kasu.js.map +1 -1
- package/dist/facade/strategies.d.ts +25 -0
- package/dist/facade/strategies.js +36 -0
- package/dist/facade/strategies.js.map +1 -1
- package/dist/facade/types.d.ts +57 -2
- package/dist/facade/unused-pool-ids.d.ts +31 -0
- package/dist/facade/unused-pool-ids.js +57 -0
- package/dist/facade/unused-pool-ids.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/sdk-config.d.ts +11 -0
- package/dist/sdk-config.js +8 -1
- package/dist/sdk-config.js.map +1 -1
- package/dist/services/DataService/data-service.d.ts +5 -0
- package/dist/services/DataService/data-service.js +7 -2
- package/dist/services/DataService/data-service.js.map +1 -1
- package/dist/services/Locking/calculate-apy.test.d.ts +1 -0
- package/dist/services/Locking/calculate-apy.test.js +41 -0
- package/dist/services/Locking/calculate-apy.test.js.map +1 -0
- package/dist/services/Locking/locking.d.ts +14 -0
- package/dist/services/Locking/locking.js +16 -2
- package/dist/services/Locking/locking.js.map +1 -1
- package/dist/tests/sample.test.js +46 -25
- package/dist/tests/sample.test.js.map +1 -1
- package/package.json +43 -12
- package/src/domain/deposit-bounds.test.ts +200 -0
- package/src/domain/deposit-bounds.ts +118 -0
- package/src/domain/index.ts +70 -0
- package/src/domain/partners.test.ts +83 -0
- package/src/domain/partners.ts +66 -0
- package/src/domain/pools.test.ts +260 -0
- package/src/domain/pools.ts +150 -0
- package/src/domain/rates.test.ts +254 -0
- package/src/domain/rates.ts +120 -0
- package/src/domain/tranche-display-name.test.ts +96 -0
- package/src/domain/tranche-display-name.ts +34 -0
- package/src/domain/tranches.test.ts +300 -0
- package/src/domain/tranches.ts +177 -0
- package/src/facade/chain-configs.ts +55 -0
- package/src/facade/config.test.ts +265 -0
- package/src/facade/deposits.ts +25 -0
- package/src/{tests → facade}/facade.test.ts +10 -5
- package/src/facade/index.ts +4 -0
- package/src/facade/kasu.ts +155 -12
- package/src/facade/strategies.ts +36 -0
- package/src/facade/types.ts +60 -2
- package/src/facade/unused-pool-ids.ts +59 -0
- package/src/index.ts +6 -0
- package/src/sdk-config.ts +19 -1
- package/src/services/DataService/data-service.ts +7 -2
- package/src/services/Locking/calculate-apy.test.ts +52 -0
- package/src/services/Locking/locking.ts +16 -2
- package/src/tests/sample.test.ts +54 -33
- package/.eslintignore +0 -3
- package/.eslintrc.json +0 -90
- package/.github/workflows/ci.yml +0 -39
- package/.github/workflows/gitleaks.yml +0 -22
- package/.github/workflows/main.yml +0 -41
- package/.gitleaksignore +0 -19
- package/.prettierignore +0 -3
- package/.prettierrc +0 -28
- package/CLAUDE.md +0 -410
- package/copy-abi.ps1 +0 -15
- package/dist/tests/facade.test.js.map +0 -1
- package/index.ts +0 -2
- package/jest-config.ts +0 -9
- package/jest.config.js +0 -8
- package/rollup.config.js +0 -45
- package/tsconfig.json +0 -27
- /package/dist/{tests/facade.test.d.ts → domain/deposit-bounds.test.d.ts} +0 -0
|
@@ -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,70 @@
|
|
|
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. The one exception is
|
|
12
|
+
* `getTrancheDisplayName`, which exists precisely because that rename must
|
|
13
|
+
* NOT drift between apps.
|
|
14
|
+
* 2. **Pure.** No network, no clock, no environment. Anything with I/O belongs
|
|
15
|
+
* in `facade/` (see `fetchUnusedPoolIds`).
|
|
16
|
+
*/
|
|
17
|
+
export {
|
|
18
|
+
apyToEpochRate,
|
|
19
|
+
epochRateToApy,
|
|
20
|
+
EPOCHS_IN_YEAR,
|
|
21
|
+
netEffectiveApy,
|
|
22
|
+
} from './rates';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
ceilToCents,
|
|
26
|
+
floorToCents,
|
|
27
|
+
isBelowMinimumCapacity,
|
|
28
|
+
MAX_LENDING_AMOUNT_FALLBACK,
|
|
29
|
+
MIN_LENDING_AMOUNT_FALLBACK,
|
|
30
|
+
parseTrancheBound,
|
|
31
|
+
resolveBoundShortcuts,
|
|
32
|
+
resolveDepositBounds,
|
|
33
|
+
} from './deposit-bounds';
|
|
34
|
+
export type { BoundShortcuts, DepositBounds } from './deposit-bounds';
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
APXIUM,
|
|
38
|
+
getCreditOriginator,
|
|
39
|
+
getInstitutionalLender,
|
|
40
|
+
INVOICEMATE,
|
|
41
|
+
RIXON_CAPITAL,
|
|
42
|
+
} from './partners';
|
|
43
|
+
export type { PoolNameSignal, StrategyPartner } from './partners';
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
maxNetRateCeiling,
|
|
47
|
+
pickHighestYieldTranche,
|
|
48
|
+
poolMaxApy,
|
|
49
|
+
selectVisiblePools,
|
|
50
|
+
} from './pools';
|
|
51
|
+
export type { BestTranche } from './pools';
|
|
52
|
+
|
|
53
|
+
export { getTrancheDisplayName, UPPER_MEZZANINE } from './tranche-display-name';
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
compareTrancheSeniority,
|
|
57
|
+
derivePoolStatus,
|
|
58
|
+
MIN_TRANCHE_CAPACITY,
|
|
59
|
+
netTrancheApyBounds,
|
|
60
|
+
pickDefaultTrancheId,
|
|
61
|
+
poolAllTranchesFull,
|
|
62
|
+
trancheApyBounds,
|
|
63
|
+
trancheHasCapacity,
|
|
64
|
+
trancheRiskRank,
|
|
65
|
+
} from './tranches';
|
|
66
|
+
export type {
|
|
67
|
+
ApyBounds,
|
|
68
|
+
PoolStatus,
|
|
69
|
+
TrancheCapacitySignal,
|
|
70
|
+
} from './tranches';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APXIUM,
|
|
3
|
+
getCreditOriginator,
|
|
4
|
+
getInstitutionalLender,
|
|
5
|
+
INVOICEMATE,
|
|
6
|
+
RIXON_CAPITAL,
|
|
7
|
+
} from './partners';
|
|
8
|
+
|
|
9
|
+
// Ported from kasu-ui `src/features/lending/lib/strategy-partners.test.ts`.
|
|
10
|
+
|
|
11
|
+
describe('getCreditOriginator', () => {
|
|
12
|
+
it('maps "Payment Finance (PayFi)" to InvoiceMate', () => {
|
|
13
|
+
expect(getCreditOriginator({ poolName: 'Payment Finance (PayFi)' })).toBe(
|
|
14
|
+
INVOICEMATE,
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('keeps Apxium receivables pools as Apxium (regression: do not match "invoice")', () => {
|
|
19
|
+
// The bug: matching the asset class ("invoice financing") wrongly
|
|
20
|
+
// classified Apxium receivables pools as InvoiceMate. Name-only
|
|
21
|
+
// matching fixes it — none of these names contain payfi/payment
|
|
22
|
+
// finance.
|
|
23
|
+
expect(getCreditOriginator({ poolName: 'Professional Fee Funding' })).toBe(
|
|
24
|
+
APXIUM,
|
|
25
|
+
);
|
|
26
|
+
expect(getCreditOriginator({ poolName: 'Whole Ledger Funding' })).toBe(
|
|
27
|
+
APXIUM,
|
|
28
|
+
);
|
|
29
|
+
expect(
|
|
30
|
+
getCreditOriginator({ poolName: 'Taxation Funding (Tax Pay)' }),
|
|
31
|
+
).toBe(APXIUM);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('is case-insensitive and matches the "payfi" / "payment finance" name', () => {
|
|
35
|
+
expect(getCreditOriginator({ poolName: 'payment finance' })).toBe(
|
|
36
|
+
INVOICEMATE,
|
|
37
|
+
);
|
|
38
|
+
expect(getCreditOriginator({ poolName: 'PAYFI Strategy' })).toBe(
|
|
39
|
+
INVOICEMATE,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('defaults to Apxium for empty / missing names', () => {
|
|
44
|
+
expect(getCreditOriginator({ poolName: '' })).toBe(APXIUM);
|
|
45
|
+
expect(getCreditOriginator({})).toBe(APXIUM);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getCreditOriginator — the widened PoolNameSignal', () => {
|
|
50
|
+
it('reads the facade Strategy shape (`name`)', () => {
|
|
51
|
+
expect(getCreditOriginator({ name: 'Payment Finance (PayFi)' })).toBe(
|
|
52
|
+
INVOICEMATE,
|
|
53
|
+
);
|
|
54
|
+
expect(getCreditOriginator({ name: 'Taxation Funding' })).toBe(APXIUM);
|
|
55
|
+
expect(getCreditOriginator({ name: null })).toBe(APXIUM);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reads a bare string', () => {
|
|
59
|
+
expect(getCreditOriginator('Payment Finance (PayFi)')).toBe(INVOICEMATE);
|
|
60
|
+
expect(getCreditOriginator('Whole Ledger Funding')).toBe(APXIUM);
|
|
61
|
+
expect(getCreditOriginator('')).toBe(APXIUM);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('prefers poolName when an object carries both', () => {
|
|
65
|
+
const both = { poolName: 'Taxation Funding', name: 'Payment Finance' };
|
|
66
|
+
expect(getCreditOriginator(both)).toBe(APXIUM);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('treats an explicitly null poolName as missing, not as a match', () => {
|
|
70
|
+
expect(getCreditOriginator({ poolName: null })).toBe(APXIUM);
|
|
71
|
+
expect(getCreditOriginator({ poolName: undefined })).toBe(APXIUM);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('getInstitutionalLender', () => {
|
|
76
|
+
it('gives Apxium pools Rixon Capital', () => {
|
|
77
|
+
expect(getInstitutionalLender(APXIUM)).toBe(RIXON_CAPITAL);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('gives InvoiceMate pools no senior lender', () => {
|
|
81
|
+
expect(getInstitutionalLender(INVOICEMATE)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Credit Originator + Institutional (senior) Lender are not exposed as Directus
|
|
2
|
+
// or subgraph pool fields, so we derive them from the pool name. Confirmed
|
|
3
|
+
// mapping (2026-06): the only InvoiceMate strategy is "Payment Finance
|
|
4
|
+
// (PayFi)"; every other pool — Whole Ledger / Taxation / Professional Fee
|
|
5
|
+
// Funding — is Apxium. Apxium → Rixon Capital (senior lender); InvoiceMate →
|
|
6
|
+
// none. Replace this with a real Directus field if/when one ships.
|
|
7
|
+
export interface StrategyPartner {
|
|
8
|
+
name: string;
|
|
9
|
+
url: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const APXIUM: StrategyPartner = {
|
|
13
|
+
name: 'Apxium',
|
|
14
|
+
url: 'https://www.apxium.com/',
|
|
15
|
+
};
|
|
16
|
+
export const INVOICEMATE: StrategyPartner = {
|
|
17
|
+
name: 'InvoiceMate',
|
|
18
|
+
url: 'https://invoicemate.net/',
|
|
19
|
+
};
|
|
20
|
+
export const RIXON_CAPITAL: StrategyPartner = {
|
|
21
|
+
name: 'Rixon Capital',
|
|
22
|
+
url: 'https://rixon.capital/',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Anything a pool name can arrive as. The raw `PoolOverview` carries
|
|
27
|
+
* `poolName`, the facade's `Strategy` carries `name`, and a caller who already
|
|
28
|
+
* has the string can pass it directly — one rule, whichever shape reaches it.
|
|
29
|
+
* `poolName` wins when an object carries both and it is set.
|
|
30
|
+
*/
|
|
31
|
+
export type PoolNameSignal =
|
|
32
|
+
| string
|
|
33
|
+
| { poolName?: string | null }
|
|
34
|
+
| { name?: string | null };
|
|
35
|
+
|
|
36
|
+
function poolNameOf(pool: PoolNameSignal): string {
|
|
37
|
+
if (typeof pool === 'string') return pool;
|
|
38
|
+
const signal: { poolName?: string | null; name?: string | null } = pool;
|
|
39
|
+
return signal.poolName ?? signal.name ?? '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Credit-originator inference by pool NAME only. The sole InvoiceMate
|
|
44
|
+
* strategy is "Payment Finance (PayFi)" (matched by "payfi" / "payment
|
|
45
|
+
* finance"); every other pool — Whole Ledger / Taxation / Professional Fee
|
|
46
|
+
* Funding — is Apxium.
|
|
47
|
+
*
|
|
48
|
+
* We deliberately do NOT match on asset class / subheading: Apxium's pools
|
|
49
|
+
* are receivables/invoice financing, so their asset class contains
|
|
50
|
+
* "invoice" and would be misclassified as InvoiceMate.
|
|
51
|
+
*/
|
|
52
|
+
export function getCreditOriginator(pool: PoolNameSignal): StrategyPartner {
|
|
53
|
+
const name = poolNameOf(pool).toLowerCase();
|
|
54
|
+
if (/payfi|payment finance/.test(name)) return INVOICEMATE;
|
|
55
|
+
return APXIUM;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Apxium pools carry an institutional senior lender (Rixon Capital);
|
|
60
|
+
* InvoiceMate pools have none.
|
|
61
|
+
*/
|
|
62
|
+
export function getInstitutionalLender(
|
|
63
|
+
originator: StrategyPartner,
|
|
64
|
+
): StrategyPartner | null {
|
|
65
|
+
return originator.name === APXIUM.name ? RIXON_CAPITAL : null;
|
|
66
|
+
}
|