@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.
- package/LICENSE +21 -0
- package/README.md +201 -253
- package/dist/bundle.cjs.js +5472 -1710
- package/dist/bundle.esm.js +5415 -1713
- package/dist/domain/au-minimum.d.ts +135 -0
- package/dist/domain/au-minimum.js +154 -0
- package/dist/domain/au-minimum.js.map +1 -0
- package/dist/domain/au-minimum.test.js +202 -0
- package/dist/domain/au-minimum.test.js.map +1 -0
- 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.d.ts +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 +40 -0
- package/dist/domain/index.js +33 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/loan-contract.d.ts +174 -0
- package/dist/domain/loan-contract.js +160 -0
- package/dist/domain/loan-contract.js.map +1 -0
- package/dist/domain/loan-contract.test.d.ts +1 -0
- package/dist/domain/loan-contract.test.js +255 -0
- package/dist/domain/loan-contract.test.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/requests.d.ts +181 -0
- package/dist/domain/requests.js +202 -0
- package/dist/domain/requests.js.map +1 -0
- package/dist/domain/requests.test.d.ts +1 -0
- package/dist/domain/requests.test.js +470 -0
- package/dist/domain/requests.test.js.map +1 -0
- package/dist/domain/settlement.d.ts +97 -0
- package/dist/domain/settlement.js +117 -0
- package/dist/domain/settlement.js.map +1 -0
- package/dist/domain/settlement.test.d.ts +1 -0
- package/dist/domain/settlement.test.js +152 -0
- package/dist/domain/settlement.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/domain/wallet-errors.d.ts +37 -0
- package/dist/domain/wallet-errors.js +56 -0
- package/dist/domain/wallet-errors.js.map +1 -0
- package/dist/domain/wallet-errors.test.d.ts +1 -0
- package/dist/domain/wallet-errors.test.js +71 -0
- package/dist/domain/wallet-errors.test.js.map +1 -0
- package/dist/facade/chain-configs.js +61 -1
- 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 +87 -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/facade/user-portfolio.d.ts +18 -0
- package/dist/facade/user-portfolio.js +23 -0
- package/dist/facade/user-portfolio.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -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 +10 -12
- package/dist/services/DataService/data-service.js.map +1 -1
- package/dist/services/DataService/directus-client.d.ts +26 -0
- package/dist/services/DataService/directus-client.js +38 -0
- package/dist/services/DataService/directus-client.js.map +1 -0
- 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/services/UserLending/user-lending.js +11 -7
- package/dist/services/UserLending/user-lending.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/au-minimum.test.ts +371 -0
- package/src/domain/au-minimum.ts +192 -0
- package/src/domain/deposit-bounds.test.ts +200 -0
- package/src/domain/deposit-bounds.ts +118 -0
- package/src/domain/index.ts +134 -0
- package/src/domain/loan-contract.test.ts +343 -0
- package/src/domain/loan-contract.ts +275 -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/requests.test.ts +653 -0
- package/src/domain/requests.ts +414 -0
- package/src/domain/settlement.test.ts +198 -0
- package/src/domain/settlement.ts +161 -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/domain/wallet-errors.test.ts +100 -0
- package/src/domain/wallet-errors.ts +56 -0
- package/src/facade/chain-configs.ts +62 -1
- package/src/facade/config.test.ts +265 -0
- package/src/facade/deposits.ts +25 -0
- package/src/{tests → facade}/facade.test.ts +134 -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/facade/user-portfolio.ts +24 -0
- package/src/index.ts +8 -0
- package/src/sdk-config.ts +19 -1
- package/src/services/DataService/data-service.ts +14 -26
- package/src/services/DataService/directus-client.ts +54 -0
- package/src/services/Locking/calculate-apy.test.ts +52 -0
- package/src/services/Locking/locking.ts +16 -2
- package/src/services/UserLending/user-lending.ts +17 -21
- 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/au-minimum.test.d.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { PoolOverview, TrancheData } from '../services/DataService/types';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
maxNetRateCeiling,
|
|
5
|
+
pickHighestYieldTranche,
|
|
6
|
+
poolMaxApy,
|
|
7
|
+
selectVisiblePools,
|
|
8
|
+
} from './pools';
|
|
9
|
+
import { netEffectiveApy } from './rates';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ported from kasu-ui `select-visible-pools.test.ts` and the
|
|
13
|
+
* `pickHighestYieldTranche` / `formatMaxNetRate` cases of
|
|
14
|
+
* `pick-best-tranche.test.ts`. The `formatMaxNetRate` assertions are on the
|
|
15
|
+
* ceiling NUMBER here; their rendered strings stay in kasu-ui.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function tranche(
|
|
19
|
+
name: string,
|
|
20
|
+
apy: string,
|
|
21
|
+
poolCapacity: string,
|
|
22
|
+
maxApy?: string,
|
|
23
|
+
): TrancheData {
|
|
24
|
+
return { id: name, name, apy, poolCapacity, maxApy } as unknown as TrancheData;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pool(over: Partial<PoolOverview>): PoolOverview {
|
|
28
|
+
return {
|
|
29
|
+
id: 'p',
|
|
30
|
+
poolName: 'p',
|
|
31
|
+
isActive: true,
|
|
32
|
+
isOversubscribed: false,
|
|
33
|
+
tranches: [tranche('Senior', '0.09', '250000', '0.10')],
|
|
34
|
+
...over,
|
|
35
|
+
} as unknown as PoolOverview;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const withCapacity = (): TrancheData[] => [
|
|
39
|
+
tranche('Senior', '0.09', '250000', '0.10'),
|
|
40
|
+
];
|
|
41
|
+
const noCapacity = (): TrancheData[] => [tranche('Senior', '0.09', '0', '0.10')];
|
|
42
|
+
|
|
43
|
+
/** The live performance fee on all deployments. */
|
|
44
|
+
const FEE = 10;
|
|
45
|
+
|
|
46
|
+
describe('poolMaxApy', () => {
|
|
47
|
+
it('takes the highest usable maxApy across tranches', () => {
|
|
48
|
+
expect(
|
|
49
|
+
poolMaxApy(
|
|
50
|
+
pool({
|
|
51
|
+
tranches: [
|
|
52
|
+
tranche('Senior', '0.09', '1', '0.10'),
|
|
53
|
+
tranche('Junior', '0.12', '1', '0.18'),
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
).toBe(0.18);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('is 0 when nothing is usable', () => {
|
|
61
|
+
expect(poolMaxApy(pool({ tranches: [] }))).toBe(0);
|
|
62
|
+
expect(
|
|
63
|
+
poolMaxApy(pool({ tranches: [tranche('Senior', '0.09', '1', 'x')] })),
|
|
64
|
+
).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('selectVisiblePools', () => {
|
|
69
|
+
it('drops inactive pools', () => {
|
|
70
|
+
const pools = [
|
|
71
|
+
pool({ id: 'active', isActive: true }),
|
|
72
|
+
pool({ id: 'inactive', isActive: false }),
|
|
73
|
+
];
|
|
74
|
+
expect(selectVisiblePools(pools).map((p) => p.id)).toEqual(['active']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('drops oversubscribed pools', () => {
|
|
78
|
+
const pools = [
|
|
79
|
+
pool({ id: 'open', isOversubscribed: false }),
|
|
80
|
+
pool({ id: 'full', isOversubscribed: true }),
|
|
81
|
+
];
|
|
82
|
+
expect(selectVisiblePools(pools).map((p) => p.id)).toEqual(['open']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('sorts pools with capacity ahead of pools without', () => {
|
|
86
|
+
const pools = [
|
|
87
|
+
pool({ id: 'empty', tranches: noCapacity() }),
|
|
88
|
+
pool({ id: 'has-cap', tranches: withCapacity() }),
|
|
89
|
+
];
|
|
90
|
+
expect(selectVisiblePools(pools).map((p) => p.id)).toEqual([
|
|
91
|
+
'has-cap',
|
|
92
|
+
'empty',
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('sorts by highest max APY within the same capacity bucket', () => {
|
|
97
|
+
const pools = [
|
|
98
|
+
pool({
|
|
99
|
+
id: 'low',
|
|
100
|
+
tranches: [tranche('Senior', '0.08', '250000', '0.08')],
|
|
101
|
+
}),
|
|
102
|
+
pool({
|
|
103
|
+
id: 'high',
|
|
104
|
+
tranches: [tranche('Senior', '0.15', '250000', '0.15')],
|
|
105
|
+
}),
|
|
106
|
+
];
|
|
107
|
+
expect(selectVisiblePools(pools).map((p) => p.id)).toEqual([
|
|
108
|
+
'high',
|
|
109
|
+
'low',
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not mutate the input array', () => {
|
|
114
|
+
const pools = [
|
|
115
|
+
pool({ id: 'a', tranches: noCapacity() }),
|
|
116
|
+
pool({ id: 'b', tranches: withCapacity() }),
|
|
117
|
+
];
|
|
118
|
+
const before = pools.map((p) => p.id);
|
|
119
|
+
selectVisiblePools(pools);
|
|
120
|
+
expect(pools.map((p) => p.id)).toEqual(before);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('pickHighestYieldTranche', () => {
|
|
125
|
+
const named = (poolName: string, tranches: TrancheData[]): PoolOverview =>
|
|
126
|
+
pool({ id: poolName, poolName, tranches });
|
|
127
|
+
|
|
128
|
+
it('returns null for empty / nullish input', () => {
|
|
129
|
+
expect(pickHighestYieldTranche(undefined)).toBeNull();
|
|
130
|
+
expect(pickHighestYieldTranche(null)).toBeNull();
|
|
131
|
+
expect(pickHighestYieldTranche([])).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('picks the highest base APY across all pools with capacity', () => {
|
|
135
|
+
const best = pickHighestYieldTranche([
|
|
136
|
+
named('A', [tranche('Junior', '0.12', '5000')]),
|
|
137
|
+
named('B', [
|
|
138
|
+
tranche('Senior', '0.18', '5000'),
|
|
139
|
+
tranche('Mezz', '0.15', '5000'),
|
|
140
|
+
]),
|
|
141
|
+
]);
|
|
142
|
+
expect(best?.pool.poolName).toBe('B');
|
|
143
|
+
expect(best?.tranche.name).toBe('Senior');
|
|
144
|
+
expect(best?.apy).toBeCloseTo(0.18);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('skips tranches without capacity (< 1 unit)', () => {
|
|
148
|
+
const best = pickHighestYieldTranche([
|
|
149
|
+
named('Full', [tranche('Junior', '0.30', '0')]),
|
|
150
|
+
named('Live', [tranche('Senior', '0.10', '2000')]),
|
|
151
|
+
]);
|
|
152
|
+
expect(best?.pool.poolName).toBe('Live');
|
|
153
|
+
expect(best?.apy).toBeCloseTo(0.1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('breaks APY ties toward more remaining capacity', () => {
|
|
157
|
+
const best = pickHighestYieldTranche([
|
|
158
|
+
named('Small', [tranche('Junior', '0.15', '1000')]),
|
|
159
|
+
named('Big', [tranche('Junior', '0.15', '9000')]),
|
|
160
|
+
]);
|
|
161
|
+
expect(best?.pool.poolName).toBe('Big');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('ignores zero / non-finite APY values', () => {
|
|
165
|
+
const best = pickHighestYieldTranche([
|
|
166
|
+
named('Zero', [tranche('Junior', '0', '5000')]),
|
|
167
|
+
named('NaN', [tranche('Junior', 'not-a-number', '5000')]),
|
|
168
|
+
named('Real', [tranche('Junior', '0.09', '5000')]),
|
|
169
|
+
]);
|
|
170
|
+
expect(best?.pool.poolName).toBe('Real');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('reads the BASE rate, not the FTD-widened maxApy', () => {
|
|
174
|
+
// A ceiling claim and a named tranche's rate are different statements.
|
|
175
|
+
const best = pickHighestYieldTranche([
|
|
176
|
+
named('A', [tranche('Junior', '0.12', '5000', '0.30')]),
|
|
177
|
+
named('B', [tranche('Senior', '0.18', '5000', '0.18')]),
|
|
178
|
+
]);
|
|
179
|
+
expect(best?.pool.poolName).toBe('B');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('maxNetRateCeiling', () => {
|
|
184
|
+
const named = (poolName: string, tranches: TrancheData[]): PoolOverview =>
|
|
185
|
+
pool({ id: poolName, poolName, tranches });
|
|
186
|
+
|
|
187
|
+
it('returns the highest ceiling across all pools, net of the fee', () => {
|
|
188
|
+
const rate = maxNetRateCeiling(
|
|
189
|
+
[
|
|
190
|
+
named('A', [tranche('Junior', '0.12', '5000', '0.18')]),
|
|
191
|
+
named('B', [tranche('Senior', '0.10', '5000', '0.22')]),
|
|
192
|
+
],
|
|
193
|
+
FEE,
|
|
194
|
+
);
|
|
195
|
+
// 0.22 gross → 19.60% net, the same arithmetic as everywhere else.
|
|
196
|
+
expect(rate).toBe(netEffectiveApy(0.22, FEE));
|
|
197
|
+
expect(rate).toBeCloseTo(0.196, 4);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('prefers the FTD-inclusive maxApy over the base rate', () => {
|
|
201
|
+
// The strategies card's range tops out at maxApy; an "up to" claim
|
|
202
|
+
// quoting the base rate would contradict it on the same screen.
|
|
203
|
+
expect(
|
|
204
|
+
maxNetRateCeiling(
|
|
205
|
+
[named('A', [tranche('Junior', '0.10', '5000', '0.22')])],
|
|
206
|
+
FEE,
|
|
207
|
+
),
|
|
208
|
+
).toBe(netEffectiveApy(0.22, FEE));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('falls back to the base rate when a tranche carries no maxApy', () => {
|
|
212
|
+
expect(
|
|
213
|
+
maxNetRateCeiling(
|
|
214
|
+
[named('A', [tranche('Junior', '0.22', '5000')])],
|
|
215
|
+
FEE,
|
|
216
|
+
),
|
|
217
|
+
).toBe(netEffectiveApy(0.22, FEE));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('skips tranches without capacity — an unreachable rate is not a claim', () => {
|
|
221
|
+
const rate = maxNetRateCeiling(
|
|
222
|
+
[
|
|
223
|
+
named('Full', [tranche('Junior', '0.30', '0', '0.30')]),
|
|
224
|
+
named('Live', [tranche('Senior', '0.10', '5000', '0.10')]),
|
|
225
|
+
],
|
|
226
|
+
FEE,
|
|
227
|
+
);
|
|
228
|
+
expect(rate).toBe(netEffectiveApy(0.1, FEE));
|
|
229
|
+
expect(rate).toBeCloseTo(0.0896, 4);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('returns null rather than a gross figure when the fee is out of domain', () => {
|
|
233
|
+
const pools = [named('A', [tranche('Junior', '0.22', '5000', '0.22')])];
|
|
234
|
+
expect(maxNetRateCeiling(pools, 1000)).toBeNull();
|
|
235
|
+
expect(maxNetRateCeiling(pools, -1)).toBeNull();
|
|
236
|
+
expect(maxNetRateCeiling(pools, NaN)).toBeNull();
|
|
237
|
+
// A 100% fee leaves nothing to claim.
|
|
238
|
+
expect(maxNetRateCeiling(pools, 100)).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('returns null for empty, nullish or rate-less input', () => {
|
|
242
|
+
expect(maxNetRateCeiling(undefined, FEE)).toBeNull();
|
|
243
|
+
expect(maxNetRateCeiling(null, FEE)).toBeNull();
|
|
244
|
+
expect(maxNetRateCeiling([], FEE)).toBeNull();
|
|
245
|
+
expect(
|
|
246
|
+
maxNetRateCeiling(
|
|
247
|
+
[named('Zero', [tranche('Junior', '0', '5000', '0')])],
|
|
248
|
+
FEE,
|
|
249
|
+
),
|
|
250
|
+
).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('agrees with the top of the tranche range it must sit alongside', () => {
|
|
254
|
+
// Both read maxApy through netEffectiveApy; they must not disagree.
|
|
255
|
+
const tranches = [tranche('Senior', '0.14', '5000', '0.22')];
|
|
256
|
+
expect(maxNetRateCeiling([named('A', tranches)], FEE)).toBe(
|
|
257
|
+
netEffectiveApy(0.22, FEE),
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { PoolOverview, TrancheData } from '../services/DataService/types';
|
|
2
|
+
|
|
3
|
+
import { netEffectiveApy } from './rates';
|
|
4
|
+
import { trancheHasCapacity } from './tranches';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The highest FTD-inclusive upper bound across a pool's tranches, or `0` when
|
|
8
|
+
* none is usable. The sort key behind `selectVisiblePools`.
|
|
9
|
+
*/
|
|
10
|
+
export function poolMaxApy(pool: PoolOverview): number {
|
|
11
|
+
let hi = 0;
|
|
12
|
+
for (const t of pool.tranches) {
|
|
13
|
+
const v = parseFloat(t.maxApy);
|
|
14
|
+
if (Number.isFinite(v) && v > hi) hi = v;
|
|
15
|
+
}
|
|
16
|
+
return hi;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A lending grid's visible pool set: *active* and *not yet oversubscribed*
|
|
21
|
+
* pools, sorted with has-capacity strategies first, then by highest max APY.
|
|
22
|
+
*
|
|
23
|
+
* Shared so a server-rendered payload and a client re-fetch can never
|
|
24
|
+
* disagree — kasu-ui's SSR fetcher and client hook used to carry verbatim
|
|
25
|
+
* copies of this filter + sort, and any drift surfaced as an SSR→CSR content
|
|
26
|
+
* flip on the lending page.
|
|
27
|
+
*
|
|
28
|
+
* Pure: operates on a fresh array (`filter` output), so the in-place `sort`
|
|
29
|
+
* does not mutate the caller's input.
|
|
30
|
+
*/
|
|
31
|
+
export function selectVisiblePools(all: PoolOverview[]): PoolOverview[] {
|
|
32
|
+
const active = all.filter((p) => p.isActive && !p.isOversubscribed);
|
|
33
|
+
return active.sort((a, b) => {
|
|
34
|
+
const aHas = a.tranches.some(trancheHasCapacity) ? 1 : 0;
|
|
35
|
+
const bHas = b.tranches.some(trancheHasCapacity) ? 1 : 0;
|
|
36
|
+
if (aHas !== bHas) return bHas - aHas;
|
|
37
|
+
return poolMaxApy(b) - poolMaxApy(a);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// `tranches` is typed as a required array but arrives from the subgraph, so
|
|
42
|
+
// both scanners below read it as possibly missing — a pool with no tranche
|
|
43
|
+
// list contributes nothing instead of throwing. Mirrors the `?? []` the
|
|
44
|
+
// kasu-ui originals carried.
|
|
45
|
+
function tranchesOf(tranches: TrancheData[] | undefined): TrancheData[] {
|
|
46
|
+
return tranches ?? [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BestTranche {
|
|
50
|
+
pool: PoolOverview;
|
|
51
|
+
tranche: TrancheData;
|
|
52
|
+
/** Base variable-rate APY as a decimal fraction (e.g. `0.12`). */
|
|
53
|
+
apy: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The highest-yielding tranche that still has capacity, across all pools.
|
|
58
|
+
*
|
|
59
|
+
* Compares `tranche.apy` — the base variable rate — NOT `minApy`/`maxApy`,
|
|
60
|
+
* which the data service widens with fixed-term-deposit variants. A headline
|
|
61
|
+
* naming one tranche should advertise the rate every depositor gets, not an
|
|
62
|
+
* FTD-locked uplift.
|
|
63
|
+
*
|
|
64
|
+
* Capacity is gated by the shared `trancheHasCapacity` (≥ 1 stable unit), so
|
|
65
|
+
* full tranches never win. Ties on APY break toward more remaining capacity.
|
|
66
|
+
* Returns `null` when no tranche qualifies (empty list, all full, or all rates
|
|
67
|
+
* unset) — callers fall back to static copy.
|
|
68
|
+
*/
|
|
69
|
+
export function pickHighestYieldTranche(
|
|
70
|
+
pools?: PoolOverview[] | null,
|
|
71
|
+
): BestTranche | null {
|
|
72
|
+
if (!pools?.length) return null;
|
|
73
|
+
|
|
74
|
+
let best: BestTranche | null = null;
|
|
75
|
+
|
|
76
|
+
for (const pool of pools) {
|
|
77
|
+
for (const tranche of tranchesOf(pool.tranches)) {
|
|
78
|
+
if (!trancheHasCapacity(tranche)) continue;
|
|
79
|
+
|
|
80
|
+
const apy = parseFloat(tranche.apy);
|
|
81
|
+
if (!Number.isFinite(apy) || apy <= 0) continue;
|
|
82
|
+
|
|
83
|
+
if (!best) {
|
|
84
|
+
best = { pool, tranche, apy };
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const capacity = parseFloat(tranche.poolCapacity);
|
|
89
|
+
const bestCapacity = parseFloat(best.tranche.poolCapacity);
|
|
90
|
+
if (apy > best.apy || (apy === best.apy && capacity > bestCapacity)) {
|
|
91
|
+
best = { pool, tranche, apy };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return best;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The highest NET Effective Interest Rate on offer across the pools passed in
|
|
101
|
+
* — the ceiling behind an "up to …" claim. The numeric core of kasu-ui's
|
|
102
|
+
* `formatMaxNetRate`.
|
|
103
|
+
*
|
|
104
|
+
* `null` when there is nothing to quote, and callers must then DROP the claim
|
|
105
|
+
* rather than print a placeholder: this feeds text that gets posted publicly.
|
|
106
|
+
*
|
|
107
|
+
* Scope follows the caller's pool list, which is normally keyed to one chain —
|
|
108
|
+
* so a lender on XDC AUDD quotes the AUDD ceiling, not Base's. That is the
|
|
109
|
+
* intended reading: they are sharing the deployment they are on.
|
|
110
|
+
*
|
|
111
|
+
* Reads `maxApy` — the FTD-inclusive upper bound — unlike
|
|
112
|
+
* `pickHighestYieldTranche` above, which names one tranche's variable rate. A
|
|
113
|
+
* ceiling claim and a specific tranche's rate are different statements, and
|
|
114
|
+
* the ceiling must agree with the top of `netTrancheApyBounds`, which reads
|
|
115
|
+
* the same field. `apy` is taken into the comparison too: `maxApy` is derived
|
|
116
|
+
* from the base rate plus any fixed-term configs, so it can never be lower,
|
|
117
|
+
* and a tranche that carries no `maxApy` at all still contributes its base
|
|
118
|
+
* rate instead of dropping out.
|
|
119
|
+
*
|
|
120
|
+
* Capacity-gated by `trancheHasCapacity` — we never advertise a rate no lender
|
|
121
|
+
* could take up.
|
|
122
|
+
*
|
|
123
|
+
* FAIL CLOSED: an out-of-domain fee, or a net rate that is non-finite or ≤ 0,
|
|
124
|
+
* returns `null`. The gross figure is never a substitute, and a caller that
|
|
125
|
+
* does not yet know the fee must not pass `0`.
|
|
126
|
+
*
|
|
127
|
+
* @param feePercent the chain's performance fee, 0..100 — never a fraction.
|
|
128
|
+
*/
|
|
129
|
+
export function maxNetRateCeiling(
|
|
130
|
+
pools: PoolOverview[] | undefined | null,
|
|
131
|
+
feePercent: number,
|
|
132
|
+
): number | null {
|
|
133
|
+
if (!pools?.length) return null;
|
|
134
|
+
|
|
135
|
+
let ceiling = 0;
|
|
136
|
+
for (const pool of pools) {
|
|
137
|
+
for (const tranche of tranchesOf(pool.tranches)) {
|
|
138
|
+
if (!trancheHasCapacity(tranche)) continue;
|
|
139
|
+
for (const raw of [tranche.maxApy, tranche.apy]) {
|
|
140
|
+
const apy = parseFloat(raw);
|
|
141
|
+
if (Number.isFinite(apy) && apy > ceiling) ceiling = apy;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (ceiling <= 0) return null;
|
|
146
|
+
|
|
147
|
+
const rate = netEffectiveApy(ceiling, feePercent);
|
|
148
|
+
if (!Number.isFinite(rate) || rate <= 0) return null;
|
|
149
|
+
return rate;
|
|
150
|
+
}
|