Skip to content

Commit 2f073b1

Browse files
authored
Merge pull request #1304 from shinzoxD/test/lib-lending-markets-670
test: add unit tests for lib/lending/markets rate and utilization derivation
2 parents 45dfd85 + 22bf925 commit 2f073b1

4 files changed

Lines changed: 463 additions & 1 deletion

File tree

‎docs/markets-derivation.md‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Market rate and utilization derivation
2+
3+
How the lending UI obtains and displays supply/borrow APRs and utilization.
4+
5+
Sources:
6+
7+
- Pure helpers: [`lib/lending/markets.ts`](../lib/lending/markets.ts)
8+
- React hook: [`hooks/useMarketRates.ts`](../hooks/useMarketRates.ts)
9+
- Server stub: [`lib/markets/repository.ts`](../lib/markets/repository.ts)
10+
- Tests: [`lib/lending/markets.test.ts`](../lib/lending/markets.test.ts)
11+
12+
## Data flow
13+
14+
```
15+
Soroban lending pool (future) ──► lib/markets/repository.fetchMarkets
16+
│
17+
▼
18+
GET /api/markets
19+
│
20+
▼
21+
hooks/useMarketsData (30s cache)
22+
│
23+
▼
24+
hooks/useMarketRates(asset)
25+
│
26+
▼
27+
BorrowingForm / markets views
28+
```
29+
30+
`lib/lending/markets.ts` re-exports `useMarketRates` for the lending feature
31+
surface and owns the pure numeric helpers that pin display contracts.
32+
33+
## Utilization
34+
35+
```
36+
utilization = clamp( totalBorrow / totalSupply , 0, 1 )
37+
```
38+
39+
| Input condition | Result | Rationale |
40+
| --------------------------------------- | ------ | ---------------------------------------------- |
41+
| `totalSupply > 0`, `0 < totalBorrow ≤ S`| `B/S` | Normal pool. |
42+
| `totalSupply ≤ 0` | `0` | Empty / inverted pool — nothing to utilise. |
43+
| `totalBorrow ≤ 0` | `0` | Single-sided liquidity (supply only). |
44+
| `totalBorrow > totalSupply` | `1` | Over-utilised / accounting lag — clamp, no NaN.|
45+
| Non-finite input | `0` | Fail closed for display. |
46+
47+
Display precision: **4 decimal places** (`roundUtilization` / repository stub).
48+
49+
```ts
50+
deriveRoundedUtilization(3, 1) // 0.3333
51+
deriveRoundedUtilization(1000, 1001) // 1
52+
deriveRoundedUtilization(0, 50) // 0
53+
```
54+
55+
## APR selection
56+
57+
`useMarketRates(asset)` (and `selectBorrowApr`) look up a row by
58+
case-insensitive asset symbol and return `borrowApr`.
59+
60+
| Condition | Result |
61+
| ---------------------------------------------- | ------ |
62+
| Asset found, finite numeric `borrowApr` | that value (including `0`) |
63+
| Asset missing / empty / whitespace | `null` + error string in the hook |
64+
| `borrowApr` non-numeric or non-finite | `null` |
65+
66+
Supply APR uses the same selection rules via `selectSupplyApr`.
67+
68+
Display precision for stub APRs: **2 decimal places** (`roundApr` /
69+
`toFixed(2)` in the repository).
70+
71+
## Solvency invariant
72+
73+
For every baseline market the borrow APR is strictly above the supply APR.
74+
That spread is what keeps the pool solvent; an inversion is treated as a bug
75+
by the repository tests and by
76+
`lib/lending/markets.test.ts`.
77+
78+
## Rounding direction
79+
80+
Rounding goes through JavaScript `Number.prototype.toFixed`, which uses
81+
half-up for the common midpoints this codebase hits (`1.225` → `1.23`).
82+
Re-rounding an already-rounded APR is idempotent — `roundApr(roundApr(x)) ===
83+
roundApr(x)` for the values we display.
84+
85+
## What this issue does *not* change
86+
87+
- No change to `useMarketRates` runtime behaviour.
88+
- No change to the Soroban stub in `lib/markets/repository.ts`.
89+
- No new network calls. Tests exercise pure helpers only.

‎lib/lending/markets.test.ts‎

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
clampUnitInterval,
4+
deriveRoundedUtilization,
5+
deriveUtilization,
6+
roundApr,
7+
roundUtilization,
8+
selectBorrowApr,
9+
selectSupplyApr,
10+
type MarketRateRow,
11+
} from "./markets";
12+
13+
const sampleMarkets: MarketRateRow[] = [
14+
{
15+
asset: "USDC",
16+
supplyApr: 5.2,
17+
borrowApr: 7.8,
18+
utilization: 0.65,
19+
totalSupply: 10_000_000,
20+
totalBorrow: 6_500_000,
21+
},
22+
{
23+
asset: "XLM",
24+
supplyApr: 8.5,
25+
borrowApr: 12.0,
26+
utilization: 0.71,
27+
totalSupply: 2_500_000,
28+
totalBorrow: 1_775_000,
29+
},
30+
{
31+
asset: "BTC",
32+
supplyApr: 2.1,
33+
borrowApr: 4.5,
34+
utilization: 0.47,
35+
totalSupply: 500_000,
36+
totalBorrow: 235_000,
37+
},
38+
];
39+
40+
describe("lib/lending/markets — clampUnitInterval", () => {
41+
it("passes through values already in [0, 1]", () => {
42+
expect(clampUnitInterval(0)).toBe(0);
43+
expect(clampUnitInterval(0.5)).toBe(0.5);
44+
expect(clampUnitInterval(1)).toBe(1);
45+
});
46+
47+
it("clamps below 0 and above 1", () => {
48+
expect(clampUnitInterval(-0.01)).toBe(0);
49+
expect(clampUnitInterval(-100)).toBe(0);
50+
expect(clampUnitInterval(1.01)).toBe(1);
51+
expect(clampUnitInterval(2)).toBe(1);
52+
});
53+
54+
it("collapses non-finite values to 0", () => {
55+
expect(clampUnitInterval(Number.NaN)).toBe(0);
56+
expect(clampUnitInterval(Number.POSITIVE_INFINITY)).toBe(0);
57+
expect(clampUnitInterval(Number.NEGATIVE_INFINITY)).toBe(0);
58+
});
59+
});
60+
61+
describe("lib/lending/markets — deriveUtilization", () => {
62+
it("computes totalBorrow / totalSupply for normal pools", () => {
63+
expect(deriveUtilization(1000, 500)).toBe(0.5);
64+
expect(deriveUtilization(10_000_000, 6_500_000)).toBe(0.65);
65+
});
66+
67+
it("returns 0 for zero supply (empty pool)", () => {
68+
expect(deriveUtilization(0, 0)).toBe(0);
69+
expect(deriveUtilization(0, 100)).toBe(0);
70+
});
71+
72+
it("returns 0 for zero borrow (single-sided liquidity)", () => {
73+
expect(deriveUtilization(1_000_000, 0)).toBe(0);
74+
});
75+
76+
it("returns 0 for negative supply or borrow (inverted accounting)", () => {
77+
expect(deriveUtilization(-100, 50)).toBe(0);
78+
expect(deriveUtilization(100, -50)).toBe(0);
79+
});
80+
81+
it("clamps fully-utilised and over-utilised pools to 1", () => {
82+
expect(deriveUtilization(1000, 1000)).toBe(1);
83+
// Over-utilised: more borrowed than supplied (lag / rounding in on-chain state).
84+
expect(deriveUtilization(1000, 1001)).toBe(1);
85+
expect(deriveUtilization(100, 250)).toBe(1);
86+
});
87+
88+
it("returns 0 for non-finite inputs", () => {
89+
expect(deriveUtilization(Number.NaN, 10)).toBe(0);
90+
expect(deriveUtilization(10, Number.NaN)).toBe(0);
91+
expect(deriveUtilization(Number.POSITIVE_INFINITY, 10)).toBe(0);
92+
expect(deriveUtilization(10, Number.POSITIVE_INFINITY)).toBe(0);
93+
});
94+
95+
it("handles fractional pools without floating noise outside [0, 1]", () => {
96+
const util = deriveUtilization(3, 1);
97+
expect(util).toBeCloseTo(1 / 3, 12);
98+
expect(util).toBeGreaterThan(0);
99+
expect(util).toBeLessThan(1);
100+
});
101+
});
102+
103+
describe("lib/lending/markets — roundApr / roundUtilization", () => {
104+
it("rounds APR to two decimal places (repository stub contract)", () => {
105+
expect(roundApr(12.345)).toBe(12.35);
106+
expect(roundApr(12.344)).toBe(12.34);
107+
expect(roundApr(0)).toBe(0);
108+
expect(roundApr(7.8)).toBe(7.8);
109+
});
110+
111+
it("uses half-up ties via toFixed for APR midpoints", () => {
112+
// 1.225 → "1.23" under JS toFixed half-up for this case.
113+
expect(roundApr(1.225)).toBe(1.23);
114+
});
115+
116+
it("collapses non-finite APR to 0", () => {
117+
expect(roundApr(Number.NaN)).toBe(0);
118+
expect(roundApr(Number.POSITIVE_INFINITY)).toBe(0);
119+
});
120+
121+
it("rounds utilization to four decimal places after clamping", () => {
122+
expect(roundUtilization(0.71234)).toBe(0.7123);
123+
expect(roundUtilization(0.71235)).toBe(0.7124);
124+
expect(roundUtilization(1.5)).toBe(1);
125+
expect(roundUtilization(-0.2)).toBe(0);
126+
});
127+
128+
it("deriveRoundedUtilization combines derivation + display precision", () => {
129+
// 1 / 3 → 0.3333... → 0.3333 at 4 dp
130+
expect(deriveRoundedUtilization(3, 1)).toBe(0.3333);
131+
expect(deriveRoundedUtilization(1000, 1001)).toBe(1);
132+
expect(deriveRoundedUtilization(0, 50)).toBe(0);
133+
});
134+
});
135+
136+
describe("lib/lending/markets — selectBorrowApr / selectSupplyApr", () => {
137+
it("returns the borrow APR for a known asset", () => {
138+
expect(selectBorrowApr(sampleMarkets, "USDC")).toBe(7.8);
139+
expect(selectBorrowApr(sampleMarkets, "XLM")).toBe(12.0);
140+
});
141+
142+
it("returns the supply APR for a known asset", () => {
143+
expect(selectSupplyApr(sampleMarkets, "USDC")).toBe(5.2);
144+
expect(selectSupplyApr(sampleMarkets, "BTC")).toBe(2.1);
145+
});
146+
147+
it("normalises asset case and surrounding whitespace", () => {
148+
expect(selectBorrowApr(sampleMarkets, " usdc ")).toBe(7.8);
149+
expect(selectBorrowApr(sampleMarkets, "xlm")).toBe(12.0);
150+
expect(selectSupplyApr(sampleMarkets, "Btc")).toBe(2.1);
151+
});
152+
153+
it("returns null for unknown, empty, or missing assets", () => {
154+
expect(selectBorrowApr(sampleMarkets, "FAKE")).toBeNull();
155+
expect(selectBorrowApr(sampleMarkets, "")).toBeNull();
156+
expect(selectBorrowApr(sampleMarkets, " ")).toBeNull();
157+
expect(selectBorrowApr(sampleMarkets, null)).toBeNull();
158+
expect(selectBorrowApr(sampleMarkets, undefined)).toBeNull();
159+
expect(selectSupplyApr(sampleMarkets, "FAKE")).toBeNull();
160+
});
161+
162+
it("preserves a zero borrow APR (valid free-rate edge)", () => {
163+
const markets: MarketRateRow[] = [
164+
{ asset: "USDC", borrowApr: 0, supplyApr: 0 },
165+
];
166+
expect(selectBorrowApr(markets, "USDC")).toBe(0);
167+
expect(selectSupplyApr(markets, "USDC")).toBe(0);
168+
});
169+
170+
it("rejects non-finite APR values", () => {
171+
const markets: MarketRateRow[] = [
172+
{ asset: "USDC", borrowApr: Number.NaN, supplyApr: Number.POSITIVE_INFINITY },
173+
];
174+
expect(selectBorrowApr(markets, "USDC")).toBeNull();
175+
expect(selectSupplyApr(markets, "USDC")).toBeNull();
176+
});
177+
178+
it("rejects rows whose APR fields are missing or the wrong type", () => {
179+
const markets = [
180+
{ asset: "USDC", borrowApr: "7.8" as unknown as number },
181+
];
182+
expect(selectBorrowApr(markets, "USDC")).toBeNull();
183+
184+
const noSupply: MarketRateRow[] = [{ asset: "USDC", borrowApr: 7.8 }];
185+
expect(selectSupplyApr(noSupply, "USDC")).toBeNull();
186+
});
187+
188+
it("keeps borrow APR above supply APR for the sample baseline set", () => {
189+
// Solvency invariant of the documented baseline markets.
190+
for (const market of sampleMarkets) {
191+
const borrow = selectBorrowApr(sampleMarkets, market.asset);
192+
const supply = selectSupplyApr(sampleMarkets, market.asset);
193+
expect(borrow).not.toBeNull();
194+
expect(supply).not.toBeNull();
195+
expect(borrow!).toBeGreaterThan(supply!);
196+
}
197+
});
198+
});
199+
200+
describe("lib/lending/markets — precision stability across the range", () => {
201+
it.each([
202+
[1, 0, 0],
203+
[100, 1, 0.01],
204+
[100, 50, 0.5],
205+
[100, 99, 0.99],
206+
[100, 100, 1],
207+
[100, 150, 1],
208+
] as const)(
209+
"deriveRoundedUtilization(%i, %i) === %s",
210+
(supply, borrow, expected) => {
211+
expect(deriveRoundedUtilization(supply, borrow)).toBe(expected);
212+
},
213+
);
214+
215+
it("round-trip of rounded APR stays fixed under re-rounding", () => {
216+
const values = [0, 0.01, 1.11, 5.25, 12.99, 99.99];
217+
for (const v of values) {
218+
const once = roundApr(v);
219+
expect(roundApr(once)).toBe(once);
220+
}
221+
});
222+
});

0 commit comments

Comments
 (0)