@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,254 @@
|
|
|
1
|
+
import * as fc from 'fast-check';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
apyToEpochRate,
|
|
5
|
+
epochRateToApy,
|
|
6
|
+
EPOCHS_IN_YEAR,
|
|
7
|
+
netEffectiveApy,
|
|
8
|
+
} from './rates';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ported from kasu-ui `src/features/lending/lib/interest-rate.test.ts`. The
|
|
12
|
+
* `formatEffectiveRate` cases stay there — they assert copy and locale, which
|
|
13
|
+
* this layer does not own.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The live performance fee on all deployments (subgraph units: 10 = ten percent). */
|
|
17
|
+
const LIVE_FEE_PERCENT = 10;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Spec §9 — every distinct per-epoch rate configured on Base, read on-chain
|
|
21
|
+
* 2026-09-01, with the NET Effective Interest Rate each must display at the
|
|
22
|
+
* live 10% fee. This table IS the review sanity check: a correct
|
|
23
|
+
* implementation reproduces the whole column, including the 30% crossover row
|
|
24
|
+
* (26.30% gross nominal → 26.64% net APY) which moves UP while every other row
|
|
25
|
+
* moves down, because the two errors in the old display — no fee deducted, and
|
|
26
|
+
* a spurious de-compounding — pull in opposite directions and cross over near
|
|
27
|
+
* 28%.
|
|
28
|
+
*/
|
|
29
|
+
const GROUND_TRUTH: readonly { grossApy: number; netPercent: number }[] = [
|
|
30
|
+
{ grossApy: 0.1, netPercent: 8.96 },
|
|
31
|
+
{ grossApy: 0.105, netPercent: 9.4 },
|
|
32
|
+
{ grossApy: 0.12, netPercent: 10.74 },
|
|
33
|
+
{ grossApy: 0.14, netPercent: 12.52 },
|
|
34
|
+
{ grossApy: 0.15, netPercent: 13.41 },
|
|
35
|
+
{ grossApy: 0.16, netPercent: 14.29 },
|
|
36
|
+
{ grossApy: 0.2, netPercent: 17.84 },
|
|
37
|
+
{ grossApy: 0.22, netPercent: 19.6 },
|
|
38
|
+
{ grossApy: 0.3, netPercent: 26.64 },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
describe('netEffectiveApy — spec §9 ground truth', () => {
|
|
42
|
+
it.each(GROUND_TRUTH)(
|
|
43
|
+
'gross $grossApy at a 10% fee is $netPercent% net',
|
|
44
|
+
({ grossApy, netPercent }) => {
|
|
45
|
+
expect(netEffectiveApy(grossApy, LIVE_FEE_PERCENT) * 100).toBeCloseTo(
|
|
46
|
+
netPercent,
|
|
47
|
+
2,
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
it('reproduces the 30% crossover — the net APY is ABOVE the old gross nominal figure', () => {
|
|
53
|
+
// Old display: 52.17857 · ((1.30)^(1/52.17857) − 1) = 26.30%. New: 26.64%.
|
|
54
|
+
const oldGrossNominal = EPOCHS_IN_YEAR * (1.3 ** (1 / EPOCHS_IN_YEAR) - 1);
|
|
55
|
+
expect(oldGrossNominal * 100).toBeCloseTo(26.3, 1);
|
|
56
|
+
expect(netEffectiveApy(0.3, LIVE_FEE_PERCENT)).toBeGreaterThan(
|
|
57
|
+
oldGrossNominal,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('netEffectiveApy — units (spec §2.3)', () => {
|
|
63
|
+
it('reads feePercent as a PERCENTAGE: 22% gross at fee 10 is ≈19.602%', () => {
|
|
64
|
+
expect(netEffectiveApy(0.22, 10)).toBeCloseTo(0.19602, 5);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not silently accept the 0..1 fraction — 0.1 is a 0.1% fee, not 10%', () => {
|
|
68
|
+
const wrongUnit = netEffectiveApy(0.22, 0.1);
|
|
69
|
+
const correct = netEffectiveApy(0.22, 10);
|
|
70
|
+
// It is a finite, in-range number (we cannot reject it — 0.1% is a
|
|
71
|
+
// legal fee), so the guard cannot catch this. What protects us is that
|
|
72
|
+
// it is MATERIALLY different: ~2.37pp too high, not a plausible
|
|
73
|
+
// near-miss that could sit on screen unnoticed.
|
|
74
|
+
expect(wrongUnit).toBeCloseTo(0.21976, 5);
|
|
75
|
+
expect(wrongUnit - correct).toBeGreaterThan(0.02);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects the RAW subgraph integer (1000 = 10%, pre-integerToPercentage2)', () => {
|
|
79
|
+
expect(netEffectiveApy(0.22, 1000)).toBeNaN();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects a fee outside 0..100', () => {
|
|
83
|
+
expect(netEffectiveApy(0.22, 101)).toBeNaN();
|
|
84
|
+
expect(netEffectiveApy(0.22, -1)).toBeNaN();
|
|
85
|
+
expect(netEffectiveApy(0.22, NaN)).toBeNaN();
|
|
86
|
+
expect(netEffectiveApy(0.22, Infinity)).toBeNaN();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('accepts the domain edges', () => {
|
|
90
|
+
expect(netEffectiveApy(0.22, 0)).toBe(0.22);
|
|
91
|
+
// A 100% fee leaves the lender nothing — 0, not NaN: the fee is
|
|
92
|
+
// knowable and the answer is "you earn zero", which consumers render
|
|
93
|
+
// as "—".
|
|
94
|
+
expect(netEffectiveApy(0.22, 100)).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns NaN for a non-finite or negative gross APY (callers render "—")', () => {
|
|
98
|
+
expect(netEffectiveApy(NaN, 10)).toBeNaN();
|
|
99
|
+
expect(netEffectiveApy(Infinity, 10)).toBeNaN();
|
|
100
|
+
expect(netEffectiveApy(-Infinity, 10)).toBeNaN();
|
|
101
|
+
expect(netEffectiveApy(-0.01, 10)).toBeNaN();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The FULL in-domain range, down to and including zero and the denormals. The
|
|
106
|
+
// invariants below are the reason `netEffectiveApy` is written with
|
|
107
|
+
// `log1p`/`expm1`: the direct `(1 + x) ** n - 1` form breaks the `net <= gross`
|
|
108
|
+
// invariant for tiny rates, and the property runner finds that in a few hundred
|
|
109
|
+
// cases. Narrowing this arb would hide it.
|
|
110
|
+
const grossApyArb = fc.double({
|
|
111
|
+
min: 0,
|
|
112
|
+
max: 5,
|
|
113
|
+
noNaN: true,
|
|
114
|
+
noDefaultInfinity: true,
|
|
115
|
+
});
|
|
116
|
+
const feePercentArb = fc.double({
|
|
117
|
+
min: 0,
|
|
118
|
+
max: 100,
|
|
119
|
+
noNaN: true,
|
|
120
|
+
noDefaultInfinity: true,
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* A fee as the subgraph can actually express one: `integerToPercentage2`
|
|
124
|
+
* divides a uint by 100, so every reachable value is a 2dp multiple of 0.01 in
|
|
125
|
+
* 0..100. Used only for the STRICT-decrease property, which cannot hold for a
|
|
126
|
+
* fee of 1e-300 percent — `1 − feePercent/100` rounds to exactly 1 and the net
|
|
127
|
+
* rate is the gross one. That is arithmetic, not a defect, and no such fee
|
|
128
|
+
* exists.
|
|
129
|
+
*/
|
|
130
|
+
const realisticFeePercentArb = fc
|
|
131
|
+
.integer({ min: 1, max: 10_000 })
|
|
132
|
+
.map((n) => n / 100);
|
|
133
|
+
|
|
134
|
+
describe('netEffectiveApy — invariants', () => {
|
|
135
|
+
it('never returns more than the gross APY it came from', () => {
|
|
136
|
+
fc.assert(
|
|
137
|
+
fc.property(grossApyArb, feePercentArb, (grossApy, feePercent) => {
|
|
138
|
+
expect(netEffectiveApy(grossApy, feePercent)).toBeLessThanOrEqual(
|
|
139
|
+
grossApy,
|
|
140
|
+
);
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('equals the gross APY EXACTLY when the fee is zero', () => {
|
|
146
|
+
fc.assert(
|
|
147
|
+
// Exact equality, not "close to": if the fee is ever set to zero
|
|
148
|
+
// on-chain, the rate must be the SDK's own APY, with no float drift
|
|
149
|
+
// from the de-compound/re-compound round trip.
|
|
150
|
+
fc.property(grossApyArb, (grossApy) => {
|
|
151
|
+
expect(netEffectiveApy(grossApy, 0)).toBe(grossApy);
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('is STRICTLY below the gross APY for any real (2dp) non-zero fee', () => {
|
|
157
|
+
fc.assert(
|
|
158
|
+
fc.property(
|
|
159
|
+
grossApyArb,
|
|
160
|
+
realisticFeePercentArb,
|
|
161
|
+
(grossApy, feePercent) => {
|
|
162
|
+
if (grossApy === 0) return;
|
|
163
|
+
expect(netEffectiveApy(grossApy, feePercent)).toBeLessThan(
|
|
164
|
+
grossApy,
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('decreases monotonically as the fee rises', () => {
|
|
172
|
+
fc.assert(
|
|
173
|
+
fc.property(
|
|
174
|
+
grossApyArb,
|
|
175
|
+
feePercentArb,
|
|
176
|
+
feePercentArb,
|
|
177
|
+
(grossApy, a, b) => {
|
|
178
|
+
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
|
179
|
+
expect(netEffectiveApy(grossApy, hi)).toBeLessThanOrEqual(
|
|
180
|
+
netEffectiveApy(grossApy, lo),
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('increases monotonically as the gross APY rises', () => {
|
|
188
|
+
fc.assert(
|
|
189
|
+
fc.property(
|
|
190
|
+
grossApyArb,
|
|
191
|
+
grossApyArb,
|
|
192
|
+
feePercentArb,
|
|
193
|
+
(a, b, feePercent) => {
|
|
194
|
+
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
|
195
|
+
expect(netEffectiveApy(hi, feePercent)).toBeGreaterThanOrEqual(
|
|
196
|
+
netEffectiveApy(lo, feePercent),
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('is never negative for any in-domain input', () => {
|
|
204
|
+
fc.assert(
|
|
205
|
+
fc.property(grossApyArb, feePercentArb, (grossApy, feePercent) => {
|
|
206
|
+
expect(
|
|
207
|
+
netEffectiveApy(grossApy, feePercent),
|
|
208
|
+
).toBeGreaterThanOrEqual(0);
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('epochRateToApy / apyToEpochRate', () => {
|
|
215
|
+
it('compounds a per-epoch rate exactly as the data service always has', () => {
|
|
216
|
+
// The literal expression `calculateApyForTranche` carried inline.
|
|
217
|
+
for (const r of [0, 0.001, 0.0025, 0.003, 0.005, 0.01]) {
|
|
218
|
+
expect(epochRateToApy(r)).toBe((1 + r) ** EPOCHS_IN_YEAR - 1);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('round-trips within 1e-12 for realistic per-epoch rates', () => {
|
|
223
|
+
fc.assert(
|
|
224
|
+
fc.property(
|
|
225
|
+
// 0.0001..0.02 per epoch ≈ 0.5%..190% APY — wider than any
|
|
226
|
+
// rate the platform has configured.
|
|
227
|
+
fc.double({
|
|
228
|
+
min: 0.0001,
|
|
229
|
+
max: 0.02,
|
|
230
|
+
noNaN: true,
|
|
231
|
+
noDefaultInfinity: true,
|
|
232
|
+
}),
|
|
233
|
+
(r) => {
|
|
234
|
+
expect(apyToEpochRate(epochRateToApy(r))).toBeCloseTo(r, 12);
|
|
235
|
+
},
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('inverts the APYs the platform actually quotes', () => {
|
|
241
|
+
for (const apy of [0.1, 0.12, 0.14, 0.16, 0.22, 0.3]) {
|
|
242
|
+
expect(epochRateToApy(apyToEpochRate(apy))).toBeCloseTo(apy, 12);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('is the same de-compounding netEffectiveApy uses at a zero-ish fee', () => {
|
|
247
|
+
// A fee of exactly 0 short-circuits; the smallest fee that does not
|
|
248
|
+
// must still agree with the round trip to well under a basis point.
|
|
249
|
+
const gross = 0.22;
|
|
250
|
+
const net = netEffectiveApy(gross, 0.01);
|
|
251
|
+
expect(net).toBeLessThan(gross);
|
|
252
|
+
expect(gross - net).toBeLessThan(0.0005);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epochs in a year — the single source of truth for every rate conversion in
|
|
3
|
+
* the SDK and in every consumer that undoes one.
|
|
4
|
+
*
|
|
5
|
+
* The subgraph carries a per-epoch `interestRate`; `epochRateToApy` compounds
|
|
6
|
+
* it into an APY and `apyToEpochRate` inverts exactly that. A second copy of
|
|
7
|
+
* this number anywhere would recover a different weekly rate and every derived
|
|
8
|
+
* figure — displayed APYs, net rates, projected interest — would be wrong, so
|
|
9
|
+
* `DataService.calculateApyForTranche` and `KSULocking.calculateApy` both read
|
|
10
|
+
* it from here rather than declaring their own.
|
|
11
|
+
*/
|
|
12
|
+
export const EPOCHS_IN_YEAR = 52.17857;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A per-epoch interest rate compounded into an annual rate.
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* apy = (1 + r)^E − 1
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* This is the exact expression `DataService.calculateApyForTranche` has always
|
|
22
|
+
* used, kept in its literal `**` form rather than rewritten through
|
|
23
|
+
* `expm1`/`log1p`: it is the SDK's OWN definition of `tranche.apy`, so its
|
|
24
|
+
* float behaviour is part of the contract every consumer already calibrated
|
|
25
|
+
* against. `apyToEpochRate` is the one that must be numerically careful,
|
|
26
|
+
* because it runs on the result.
|
|
27
|
+
*
|
|
28
|
+
* @param epochRate the per-epoch rate as a 0..1 fraction (e.g. `0.003`).
|
|
29
|
+
* @returns the compounded annual rate as a 0..1 fraction.
|
|
30
|
+
*/
|
|
31
|
+
export function epochRateToApy(epochRate: number): number {
|
|
32
|
+
return (1 + epochRate) ** EPOCHS_IN_YEAR - 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The inverse of `epochRateToApy` — the per-epoch rate an APY was compounded
|
|
37
|
+
* from.
|
|
38
|
+
*
|
|
39
|
+
* `expm1`/`log1p` rather than the literal `(1 + apy) ** (1 / E) - 1`.
|
|
40
|
+
* Algebraically identical, but the direct form computes a double just above 1
|
|
41
|
+
* and subtracts 1 from it, discarding the low bits of a result that is itself
|
|
42
|
+
* ~1e-3 for a realistic rate. These two never form the intermediate near-1
|
|
43
|
+
* value.
|
|
44
|
+
*
|
|
45
|
+
* @param apy the compounded annual rate as a 0..1 fraction.
|
|
46
|
+
* @returns the per-epoch rate as a 0..1 fraction.
|
|
47
|
+
*/
|
|
48
|
+
export function apyToEpochRate(apy: number): number {
|
|
49
|
+
return Math.expm1(Math.log1p(apy) / EPOCHS_IN_YEAR);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The NET Effective Interest Rate — the compounded annual rate a lender
|
|
54
|
+
* actually earns, after the platform performance fee.
|
|
55
|
+
*
|
|
56
|
+
* ```
|
|
57
|
+
* r = (1 + grossApy)^(1/E) − 1 // the gross per-epoch rate
|
|
58
|
+
* net = (1 + r · (1 − feePercent/100))^E − 1 // fee taken each epoch, then compounded
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* `LendingPool._applyTrancheInterest` mints the lender
|
|
62
|
+
* `interest × (1 − performanceFee)` at every epoch close, and it is that NET
|
|
63
|
+
* amount which capitalises and earns interest in the following epoch. So the
|
|
64
|
+
* fee is applied per epoch and the result is re-compounded — not deducted from
|
|
65
|
+
* the annual figure.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ UNITS — the one thing that must not be got wrong.
|
|
68
|
+
* `feePercent` is a PERCENTAGE IN 0..100, **not** a 0..1 fraction.
|
|
69
|
+
* `DataService.getPerformanceFee()` (and `StrategiesFacade.getPerformanceFeePercent()`)
|
|
70
|
+
* returns the subgraph's `integerToPercentage2(1000)` = `10`, meaning **ten
|
|
71
|
+
* percent**. It is NOT `0.10`. Treating it as a fraction computes
|
|
72
|
+
* `r · (1 − 10)` = `−9r` and yields a nonsense negative rate that would render
|
|
73
|
+
* as a plausible-looking `-…%`, which is why `feePercent > 100` and
|
|
74
|
+
* `feePercent < 0` both return `NaN` and why the parameter is named
|
|
75
|
+
* `feePercent` and never `fee`.
|
|
76
|
+
*
|
|
77
|
+
* This returns a NUMBER and nothing else. Rendering it — the 2dp convention,
|
|
78
|
+
* the ` p.a.` suffix, and the fail-closed em-dash for a `NaN` or non-positive
|
|
79
|
+
* result — is the consumer's job: kasu-ui does it in
|
|
80
|
+
* `src/features/lending/lib/interest-rate.ts` (`formatEffectiveRate`,
|
|
81
|
+
* `RATE_UNAVAILABLE`), which now wraps this function instead of restating it.
|
|
82
|
+
*
|
|
83
|
+
* @param grossApy compounded GROSS APY as a 0..1 fraction (e.g. `0.22` for
|
|
84
|
+
* 22%), exactly as the SDK carries it on `tranche.apy` / `tranche.maxApy`.
|
|
85
|
+
* @param feePercent the chain's performance fee as a percentage, `0..100`
|
|
86
|
+
* (e.g. `10` for the live 10% fee).
|
|
87
|
+
* @returns the net compounded annual rate as a 0..1 fraction, or `NaN` when
|
|
88
|
+
* either input is outside its domain. Callers render `NaN` as "—".
|
|
89
|
+
*/
|
|
90
|
+
export function netEffectiveApy(grossApy: number, feePercent: number): number {
|
|
91
|
+
if (!Number.isFinite(grossApy) || grossApy < 0) return NaN;
|
|
92
|
+
if (!Number.isFinite(feePercent) || feePercent < 0 || feePercent > 100) {
|
|
93
|
+
return NaN;
|
|
94
|
+
}
|
|
95
|
+
// The lender's share of each epoch's interest. Short-circuiting on `=== 1`
|
|
96
|
+
// (rather than on `feePercent === 0`) also covers a fee small enough to
|
|
97
|
+
// underflow the multiplier: if it cannot change the epoch rate, the net
|
|
98
|
+
// rate IS the gross rate, and it must be returned unchanged rather than
|
|
99
|
+
// round-tripped through the de-compound/re-compound pair below — which is
|
|
100
|
+
// exact in algebra but not in floating point (0.22 comes back as
|
|
101
|
+
// 0.22000000000000663 in the naive form). If the fee is ever set to zero
|
|
102
|
+
// on-chain, the displayed rate must equal the SDK's APY exactly.
|
|
103
|
+
const lenderShare = 1 - feePercent / 100;
|
|
104
|
+
if (lenderShare === 1) return grossApy;
|
|
105
|
+
// `expm1`/`log1p` rather than the literal `(1 + x) ** n - 1`. Algebraically
|
|
106
|
+
// identical, but the direct form computes `(1 + grossApy) ** (1 / E) - 1`
|
|
107
|
+
// by subtracting 1 from a double just above 1, discarding the low bits of a
|
|
108
|
+
// result that is itself ~1e-8 for a realistic rate. The ~1e-8 relative
|
|
109
|
+
// error that survives is enough to push the net rate ABOVE the gross one;
|
|
110
|
+
// these two never form the intermediate near-1 value.
|
|
111
|
+
const grossEpochRate = apyToEpochRate(grossApy);
|
|
112
|
+
const netEpochRate = grossEpochRate * lenderShare;
|
|
113
|
+
const net = Math.expm1(EPOCHS_IN_YEAR * Math.log1p(netEpochRate));
|
|
114
|
+
// `net ≤ grossApy` for every `lenderShare ≤ 1` is a theorem, but at the
|
|
115
|
+
// last ULP it is not a float identity — a fee of 1e-14 percent leaves a
|
|
116
|
+
// `lenderShare` one ULP below 1 and the round trip can land one ULP above
|
|
117
|
+
// `grossApy`. Clamping enforces the proven bound rather than letting a
|
|
118
|
+
// rounding artefact put a net rate above the gross rate it came from.
|
|
119
|
+
return Math.min(net, grossApy);
|
|
120
|
+
}
|