Skip to content

Commit d60ee51

Browse files
authored
feat: formal verification for rate/liquidation math, historical rate analytics, and cross-protocol ETL (#750)
- Add Kani/SMT-LIB formal verification for the interest rate model's boundary conditions (0%/100% utilization, kink continuity, monotonicity, overflow safety) in a new interest-rate-model-proofs crate. - Add Kani/SMT-LIB formal verification for liquidation math in a new liquidation-math-proofs crate, backed by a new RiskManager::apply_liquidation in lending-risk (refactored onto checked safe-math arithmetic). The proofs surface a real finding: liquidation with a bonus can worsen health factor for deeply under-collateralized positions, and prove the precise ratio condition under which it does/doesn't. - Fix pre-existing compile breakage blocking all formal-verification crates: stellarlend-safe-math's PrecisionLoss missing #[contracttype], I256::rem (no such method; use rem_euclid), and Kani dev-dependencies declared as `optional` (unsupported by current Cargo) pointing at a nonexistent crates.io version. - Extend historical rate analytics: rolling rate volatility (std dev), weighted average rates by day/week/month, derived rate-change events, and a GET /api/rates/history?asset=&from=&to=&granularity= endpoint. - Add a cross-protocol ETL pipeline (services/cross-protocol-etl) with a standardized metrics schema, a StellarLend adapter, a DefiLlama-backed adapter for Aave/Compound/Morpho/Spark, per-adapter failure isolation, data-quality checks, TVL-weighted market share, and comparison/leaderboard endpoints under /api/cross-protocol. Closes #458 Closes #459 Closes #460 Closes #461
1 parent 311962a commit d60ee51

31 files changed

Lines changed: 2658 additions & 97 deletions
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {
2+
getRateVolatility,
3+
getWeightedAverageRates,
4+
getRateChangeEvents,
5+
getRateHistoryRange,
6+
} from '../services/analytics.service';
7+
import { StellarService } from '../services/stellar.service';
8+
import { redisCacheService } from '../services/redisCache.service';
9+
import { ValidationError } from '../utils/errors';
10+
11+
describe('analytics.service — historical rate analytics', () => {
12+
let getPoolRateAtSpy: jest.SpyInstance;
13+
14+
beforeEach(() => {
15+
redisCacheService.clearAllForTests();
16+
// Deterministic, monotonically increasing borrow APY per call so
17+
// volatility/weighted-average/rate-change tests have real variance to
18+
// observe (the default simulated implementation returns a constant
19+
// rate per pool address, which would make every metric trivially zero).
20+
let call = 0;
21+
getPoolRateAtSpy = jest
22+
.spyOn(StellarService.prototype, 'getPoolRateAt')
23+
.mockImplementation(async () => {
24+
call += 1;
25+
const borrowApy = 0.05 + (call % 5) * 0.01;
26+
return {
27+
depositApy: borrowApy * 0.7,
28+
borrowApy,
29+
utilizationRate: 0.5,
30+
};
31+
});
32+
});
33+
34+
afterEach(() => {
35+
getPoolRateAtSpy.mockRestore();
36+
});
37+
38+
describe('getRateVolatility', () => {
39+
it('computes a rolling standard deviation with the requested window size', async () => {
40+
const result = await getRateVolatility({ timeRange: '7d', poolAddress: 'pool_a' }, 5);
41+
expect(result.length).toBeGreaterThan(0);
42+
for (const point of result) {
43+
expect(point.windowSize).toBe(5);
44+
expect(point.borrowApyStdDev).toBeGreaterThanOrEqual(0);
45+
expect(point.depositApyStdDev).toBeGreaterThanOrEqual(0);
46+
}
47+
// With varying rates, at least one window should show non-zero volatility.
48+
expect(result.some((p) => p.borrowApyStdDev > 0)).toBe(true);
49+
});
50+
51+
it('returns an empty array when there are fewer samples than the window size', async () => {
52+
const result = await getRateVolatility({ timeRange: '7d', poolAddress: 'pool_b' }, 1000);
53+
expect(result).toEqual([]);
54+
});
55+
});
56+
57+
describe('getWeightedAverageRates', () => {
58+
it('buckets rate points by the requested granularity', async () => {
59+
const result = await getWeightedAverageRates(
60+
{ timeRange: '30d', poolAddress: 'pool_c' },
61+
'weekly'
62+
);
63+
expect(result.length).toBeGreaterThan(0);
64+
for (const bucket of result) {
65+
expect(bucket.granularity).toBe('weekly');
66+
expect(bucket.sampleCount).toBeGreaterThan(0);
67+
expect(new Date(bucket.periodStart).getTime()).toBeLessThan(
68+
new Date(bucket.periodEnd).getTime()
69+
);
70+
}
71+
});
72+
73+
it('sorts buckets chronologically', async () => {
74+
const result = await getWeightedAverageRates(
75+
{ timeRange: '30d', poolAddress: 'pool_d' },
76+
'daily'
77+
);
78+
const starts = result.map((b) => new Date(b.periodStart).getTime());
79+
const sorted = [...starts].sort((a, b) => a - b);
80+
expect(starts).toEqual(sorted);
81+
});
82+
});
83+
84+
describe('getRateChangeEvents', () => {
85+
it('flags borrow-rate moves at or above the threshold', async () => {
86+
const events = await getRateChangeEvents({ timeRange: '7d', poolAddress: 'pool_e' }, 10);
87+
expect(events.length).toBeGreaterThan(0);
88+
for (const event of events) {
89+
expect(Math.abs(event.deltaBps)).toBeGreaterThanOrEqual(10);
90+
expect(['increase', 'decrease']).toContain(event.changeType);
91+
expect(event.governanceActionId).toBeUndefined();
92+
}
93+
});
94+
95+
it('finds no events when the threshold is set impossibly high', async () => {
96+
const events = await getRateChangeEvents(
97+
{ timeRange: '7d', poolAddress: 'pool_f' },
98+
1_000_000
99+
);
100+
expect(events).toEqual([]);
101+
});
102+
});
103+
104+
describe('getRateHistoryRange', () => {
105+
it('returns daily-bucketed points across an explicit date range', async () => {
106+
const from = '2024-01-01T00:00:00.000Z';
107+
const to = '2024-01-05T00:00:00.000Z';
108+
const result = await getRateHistoryRange({ asset: 'pool_g', from, to, granularity: 'daily' });
109+
expect(result.length).toBe(5); // Jan 1..5 inclusive at daily granularity
110+
expect(result[0]!.poolAddress).toBe('pool_g');
111+
});
112+
113+
it('rejects a `from` after `to`', async () => {
114+
await expect(
115+
getRateHistoryRange({
116+
asset: 'pool_h',
117+
from: '2024-01-05T00:00:00.000Z',
118+
to: '2024-01-01T00:00:00.000Z',
119+
})
120+
).rejects.toThrow(ValidationError);
121+
});
122+
123+
it('rejects an invalid date string', async () => {
124+
await expect(
125+
getRateHistoryRange({ asset: 'pool_i', from: 'not-a-date', to: '2024-01-01T00:00:00.000Z' })
126+
).rejects.toThrow(ValidationError);
127+
});
128+
129+
it('rejects a range that would exceed the maximum bucket count', async () => {
130+
await expect(
131+
getRateHistoryRange({
132+
asset: 'pool_j',
133+
from: '2000-01-01T00:00:00.000Z',
134+
to: '2024-01-01T00:00:00.000Z',
135+
granularity: 'hourly',
136+
})
137+
).rejects.toThrow(ValidationError);
138+
});
139+
140+
it('defaults to the last 7 days at daily granularity when no range is given', async () => {
141+
const result = await getRateHistoryRange({ asset: 'pool_k' });
142+
expect(result.length).toBeGreaterThan(0);
143+
expect(result.length).toBeLessThanOrEqual(8);
144+
});
145+
});
146+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import {
2+
refreshCrossProtocolData,
3+
getCrossProtocolComparison,
4+
computeMarketShare,
5+
getLeaderboard,
6+
} from '../services/cross-protocol-etl/etl.service';
7+
import { ProtocolAdapter, StandardizedProtocolMetrics } from '../services/cross-protocol-etl/types';
8+
import { redisCacheService } from '../services/redisCache.service';
9+
10+
function metric(overrides: Partial<StandardizedProtocolMetrics> = {}): StandardizedProtocolMetrics {
11+
return {
12+
protocol: 'test-protocol',
13+
displayName: 'Test Protocol',
14+
chain: 'test-chain',
15+
asset: 'USDC',
16+
supplyApy: 0.03,
17+
borrowApy: 0.05,
18+
tvlUsd: 1_000_000,
19+
utilizationRate: 0.6,
20+
fetchedAt: new Date().toISOString(),
21+
source: 'test',
22+
...overrides,
23+
};
24+
}
25+
26+
function fakeAdapter(protocolId: string, metrics: StandardizedProtocolMetrics[]): ProtocolAdapter {
27+
return {
28+
protocolId,
29+
displayName: protocolId,
30+
fetchMetrics: async () => metrics,
31+
};
32+
}
33+
34+
function failingAdapter(protocolId: string, error: Error): ProtocolAdapter {
35+
return {
36+
protocolId,
37+
displayName: protocolId,
38+
fetchMetrics: async () => {
39+
throw error;
40+
},
41+
};
42+
}
43+
44+
describe('cross-protocol-etl.service', () => {
45+
beforeEach(() => {
46+
redisCacheService.clearAllForTests();
47+
});
48+
49+
describe('refreshCrossProtocolData', () => {
50+
it('merges metrics from all adapters', async () => {
51+
const adapters = [
52+
fakeAdapter('a', [metric({ protocol: 'a', asset: 'USDC' })]),
53+
fakeAdapter('b', [metric({ protocol: 'b', asset: 'USDT' })]),
54+
];
55+
const result = await refreshCrossProtocolData(adapters);
56+
expect(result.metrics).toHaveLength(2);
57+
expect(result.failedSources).toEqual([]);
58+
expect(result.metrics.map((m) => m.protocol).sort()).toEqual(['a', 'b']);
59+
});
60+
61+
it('isolates a failing adapter without blocking the others', async () => {
62+
const adapters = [
63+
fakeAdapter('good', [metric({ protocol: 'good' })]),
64+
failingAdapter('bad', new Error('upstream unreachable')),
65+
];
66+
const result = await refreshCrossProtocolData(adapters);
67+
expect(result.metrics).toHaveLength(1);
68+
expect(result.metrics[0]!.protocol).toBe('good');
69+
expect(result.failedSources).toEqual(['bad']);
70+
});
71+
72+
it('filters out metrics that fail data quality checks', async () => {
73+
const adapters = [
74+
fakeAdapter('mixed', [
75+
metric({ protocol: 'mixed', asset: 'GOOD' }),
76+
metric({ protocol: 'mixed', asset: 'BAD_APY', supplyApy: 50 }), // 5000% APY — implausible
77+
metric({ protocol: 'mixed', asset: 'BAD_UTIL', utilizationRate: 1.5 }),
78+
metric({ protocol: 'mixed', asset: 'BAD_TVL', tvlUsd: -100 }),
79+
]),
80+
];
81+
const result = await refreshCrossProtocolData(adapters);
82+
expect(result.metrics).toHaveLength(1);
83+
expect(result.metrics[0]!.asset).toBe('GOOD');
84+
expect(result.qualityIssues).toHaveLength(3);
85+
expect(result.qualityIssues.map((i) => i.asset).sort()).toEqual([
86+
'BAD_APY',
87+
'BAD_TVL',
88+
'BAD_UTIL',
89+
]);
90+
});
91+
});
92+
93+
describe('getCrossProtocolComparison caching', () => {
94+
it('caches the result so a second call does not re-invoke adapters', async () => {
95+
let calls = 0;
96+
const adapters = [
97+
{
98+
protocolId: 'counted',
99+
displayName: 'counted',
100+
fetchMetrics: async () => {
101+
calls += 1;
102+
return [metric({ protocol: 'counted' })];
103+
},
104+
},
105+
];
106+
await getCrossProtocolComparison(adapters);
107+
await getCrossProtocolComparison(adapters);
108+
expect(calls).toBe(1);
109+
});
110+
});
111+
112+
describe('computeMarketShare', () => {
113+
it('computes TVL-weighted share per protocol, sorted descending', () => {
114+
const metrics = [
115+
metric({ protocol: 'big', tvlUsd: 750 }),
116+
metric({ protocol: 'small', tvlUsd: 250 }),
117+
];
118+
const shares = computeMarketShare(metrics);
119+
expect(shares).toEqual([
120+
{ protocol: 'big', tvlUsd: 750, marketSharePct: 75 },
121+
{ protocol: 'small', tvlUsd: 250, marketSharePct: 25 },
122+
]);
123+
});
124+
125+
it('sums TVL across multiple pools of the same protocol', () => {
126+
const metrics = [
127+
metric({ protocol: 'multi', asset: 'USDC', tvlUsd: 400 }),
128+
metric({ protocol: 'multi', asset: 'USDT', tvlUsd: 600 }),
129+
];
130+
const shares = computeMarketShare(metrics);
131+
expect(shares).toEqual([{ protocol: 'multi', tvlUsd: 1000, marketSharePct: 100 }]);
132+
});
133+
134+
it('returns 0% shares (not NaN) when total TVL is zero', () => {
135+
const shares = computeMarketShare([metric({ tvlUsd: 0 })]);
136+
expect(shares[0]!.marketSharePct).toBe(0);
137+
});
138+
});
139+
140+
describe('getLeaderboard', () => {
141+
it('ranks protocols by the requested metric, descending', async () => {
142+
const adapters = [
143+
fakeAdapter('x', [
144+
metric({ protocol: 'low-apy', supplyApy: 0.01, tvlUsd: 100 }),
145+
metric({ protocol: 'high-apy', supplyApy: 0.09, tvlUsd: 50 }),
146+
]),
147+
];
148+
const board = await getLeaderboard('supplyApy', 10, adapters);
149+
expect(board.map((e) => e.protocol)).toEqual(['high-apy', 'low-apy']);
150+
expect(board[0]!.rank).toBe(1);
151+
expect(board[0]!.metricValue).toBe(0.09);
152+
});
153+
154+
it('respects the limit', async () => {
155+
const adapters = [
156+
fakeAdapter(
157+
'many',
158+
Array.from({ length: 5 }, (_, i) => metric({ protocol: `p${i}`, tvlUsd: i }))
159+
),
160+
];
161+
const board = await getLeaderboard('tvlUsd', 2, adapters);
162+
expect(board).toHaveLength(2);
163+
});
164+
});
165+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import axios from 'axios';
2+
import { DefiLlamaAdapter } from '../services/cross-protocol-etl/adapters/defiLlamaAdapter';
3+
4+
jest.mock('axios');
5+
const mockedAxios = axios as jest.Mocked<typeof axios>;
6+
7+
describe('DefiLlamaAdapter', () => {
8+
it('normalizes tracked-project pools into StandardizedProtocolMetrics', async () => {
9+
mockedAxios.get.mockResolvedValueOnce({
10+
data: {
11+
status: 'success',
12+
data: [
13+
{ chain: 'Ethereum', project: 'aave-v3', symbol: 'USDC', tvlUsd: 500_000_000, apy: 3.2 },
14+
{
15+
chain: 'Ethereum',
16+
project: 'compound-v3',
17+
symbol: 'USDC',
18+
tvlUsd: 200_000_000,
19+
apy: 2.8,
20+
},
21+
// Untracked project must be filtered out.
22+
{ chain: 'Ethereum', project: 'some-random-farm', symbol: 'USDC', tvlUsd: 10, apy: 999 },
23+
],
24+
},
25+
});
26+
27+
const adapter = new DefiLlamaAdapter();
28+
const metrics = await adapter.fetchMetrics();
29+
30+
expect(metrics).toHaveLength(2);
31+
expect(metrics.map((m) => m.protocol).sort()).toEqual(['aave-v3', 'compound-v3']);
32+
33+
const aave = metrics.find((m) => m.protocol === 'aave-v3')!;
34+
expect(aave.supplyApy).toBeCloseTo(0.032);
35+
expect(aave.tvlUsd).toBe(500_000_000);
36+
expect(aave.chain).toBe('Ethereum');
37+
expect(aave.source).toBe('defillama');
38+
// Honestly zeroed, not fabricated — see adapter doc comment.
39+
expect(aave.borrowApy).toBe(0);
40+
expect(aave.utilizationRate).toBe(0);
41+
});
42+
43+
it('handles a null apy without throwing', async () => {
44+
mockedAxios.get.mockResolvedValueOnce({
45+
data: {
46+
status: 'success',
47+
data: [{ chain: 'Ethereum', project: 'spark', symbol: 'DAI', tvlUsd: 1000, apy: null }],
48+
},
49+
});
50+
51+
const adapter = new DefiLlamaAdapter();
52+
const metrics = await adapter.fetchMetrics();
53+
expect(metrics[0]!.supplyApy).toBe(0);
54+
});
55+
56+
it('propagates upstream errors so the ETL orchestrator can isolate them', async () => {
57+
mockedAxios.get.mockRejectedValueOnce(new Error('network error'));
58+
const adapter = new DefiLlamaAdapter();
59+
await expect(adapter.fetchMetrics()).rejects.toThrow('network error');
60+
});
61+
});

0 commit comments

Comments
 (0)