Skip to content

Commit 94512e5

Browse files
szhygulinclaude
andcommitted
feat: extend get_market_incident_status to Aave V3
Mirror the Compound V3 flow: iterate UiPoolDataProviderV3 reserves, derive per-reserve utilization from totalScaledVariableDebt × variableBorrowIndex / (availableLiquidity + variableDebt), and flag on isPaused / isFrozen / !isActive / utilization ≥ 0.95. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d6ced73 commit 94512e5

4 files changed

Lines changed: 209 additions & 11 deletions

File tree

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,7 @@ async function main() {
11481148
"get_market_incident_status",
11491149
{
11501150
description:
1151-
"Return an 'is anything on fire' snapshot across every registered market for a protocol + chain. For Compound V3, returns per-market pause flags, utilization, totalSupply, totalBorrow, and a `flagged` bit that's true when any pause is active or utilization ≥ 95% (borrowers trapped). Top-level `incident: true` if any market is flagged. Use this when you suspect a governance pause, a utilization cliff, or a multi-market contagion from a shared collateral exploit — it collapses what would otherwise take one get_compound_market_info call per market.",
1151+
"Return an 'is anything on fire' snapshot across every registered market for a protocol + chain. For Compound V3, returns per-market pause flags, utilization, totalSupply, totalBorrow. For Aave V3, returns per-reserve isActive/isFrozen/isPaused, utilization, totalSupplied, totalBorrowed. Each entry has a `flagged` bit: Compound flags on any pause or utilization ≥ 95% (borrowers trapped); Aave flags on paused/frozen/inactive or utilization ≥ 95%. Top-level `incident: true` if any market/reserve is flagged. Use when you suspect a governance pause, a utilization cliff, or multi-market contagion from a shared-collateral exploit — collapses what would otherwise take one get_compound_market_info call per market.",
11521152
inputSchema: getMarketIncidentStatusInput.shape,
11531153
},
11541154
handler(getMarketIncidentStatus)

src/modules/incidents/index.ts

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getClient } from "../../data/rpc.js";
33
import { CONTRACTS } from "../../config/contracts.js";
44
import { cometAbi } from "../../abis/compound-comet.js";
55
import { erc20Abi } from "../../abis/erc20.js";
6+
import { aaveUiPoolDataProviderAbi } from "../../abis/aave-ui-pool-data-provider.js";
67
import { readCometPausedActions, type CometPausedAction } from "../compound/index.js";
78
import { round } from "../../data/format.js";
89
import type { SupportedChain } from "../../types/index.js";
@@ -31,7 +32,21 @@ export interface CompoundMarketIncidentEntry {
3132
flagged: boolean;
3233
}
3334

34-
export interface MarketIncidentStatus {
35+
export interface AaveReserveIncidentEntry {
36+
chain: SupportedChain;
37+
symbol: string;
38+
underlyingAsset: `0x${string}`;
39+
isActive: boolean;
40+
isFrozen: boolean;
41+
isPaused: boolean;
42+
utilization: number;
43+
totalSupplied: string;
44+
totalBorrowed: string;
45+
/** True when any reserve is paused/frozen/inactive OR utilization ≥ 0.95. */
46+
flagged: boolean;
47+
}
48+
49+
export interface CompoundMarketIncidentStatus {
3550
protocol: "compound-v3";
3651
chain: SupportedChain;
3752
/** Block number at which reads were performed. Useful for incident reports. */
@@ -41,18 +56,42 @@ export interface MarketIncidentStatus {
4156
markets: CompoundMarketIncidentEntry[];
4257
}
4358

59+
export interface AaveMarketIncidentStatus {
60+
protocol: "aave-v3";
61+
chain: SupportedChain;
62+
blockNumber: string;
63+
incident: boolean;
64+
markets: AaveReserveIncidentEntry[];
65+
}
66+
67+
export type MarketIncidentStatus =
68+
| CompoundMarketIncidentStatus
69+
| AaveMarketIncidentStatus;
70+
4471
const HIGH_UTILIZATION_FLAG = 0.95;
72+
const RAY = 10n ** 27n;
73+
function rayMul(a: bigint, b: bigint): bigint {
74+
return (a * b + RAY / 2n) / RAY;
75+
}
4576

4677
export async function getMarketIncidentStatus(
4778
args: GetMarketIncidentStatusArgs
4879
): Promise<MarketIncidentStatus> {
4980
const chain = args.chain as SupportedChain;
50-
if (args.protocol !== "compound-v3") {
51-
throw new Error(
52-
`get_market_incident_status currently supports protocol="compound-v3" only. Requested ${args.protocol}.`
53-
);
81+
if (args.protocol === "compound-v3") {
82+
return getCompoundIncidentStatus(chain);
83+
}
84+
if (args.protocol === "aave-v3") {
85+
return getAaveIncidentStatus(chain);
5486
}
87+
throw new Error(
88+
`get_market_incident_status supports protocol="compound-v3" or "aave-v3". Requested ${args.protocol}.`
89+
);
90+
}
5591

92+
async function getCompoundIncidentStatus(
93+
chain: SupportedChain
94+
): Promise<CompoundMarketIncidentStatus> {
5695
const registry = (CONTRACTS as Record<string, Record<string, Record<string, string>>>)[
5796
chain
5897
]?.compound;
@@ -122,3 +161,70 @@ export async function getMarketIncidentStatus(
122161
markets: entries,
123162
};
124163
}
164+
165+
interface AaveReserveRaw {
166+
underlyingAsset: `0x${string}`;
167+
symbol: string;
168+
decimals: bigint;
169+
isActive: boolean;
170+
isFrozen: boolean;
171+
isPaused: boolean;
172+
variableBorrowIndex: bigint;
173+
availableLiquidity: bigint;
174+
totalScaledVariableDebt: bigint;
175+
}
176+
177+
async function getAaveIncidentStatus(
178+
chain: SupportedChain
179+
): Promise<AaveMarketIncidentStatus> {
180+
const aave = (CONTRACTS as Record<string, Record<string, Record<string, string>>>)[
181+
chain
182+
]?.aave;
183+
if (!aave) {
184+
throw new Error(`Aave V3 is not registered on ${chain}.`);
185+
}
186+
const provider = aave.poolAddressProvider as `0x${string}`;
187+
const uiProvider = aave.uiPoolDataProvider as `0x${string}`;
188+
189+
const client = getClient(chain);
190+
const blockNumber = await client.getBlockNumber();
191+
192+
const reservesResult = await client.readContract({
193+
address: uiProvider,
194+
abi: aaveUiPoolDataProviderAbi,
195+
functionName: "getReservesData",
196+
args: [provider],
197+
});
198+
const [reserves] = reservesResult as unknown as [AaveReserveRaw[], unknown];
199+
200+
const entries: AaveReserveIncidentEntry[] = reserves.map((r) => {
201+
const decimals = Number(r.decimals);
202+
const variableDebt = rayMul(r.totalScaledVariableDebt, r.variableBorrowIndex);
203+
const totalSupplied = r.availableLiquidity + variableDebt;
204+
const utilFraction =
205+
totalSupplied === 0n ? 0 : Number(variableDebt) / Number(totalSupplied);
206+
const flagged =
207+
r.isPaused || r.isFrozen || !r.isActive || utilFraction >= HIGH_UTILIZATION_FLAG;
208+
209+
return {
210+
chain,
211+
symbol: r.symbol,
212+
underlyingAsset: r.underlyingAsset,
213+
isActive: r.isActive,
214+
isFrozen: r.isFrozen,
215+
isPaused: r.isPaused,
216+
utilization: round(utilFraction, 6),
217+
totalSupplied: formatUnits(totalSupplied, decimals),
218+
totalBorrowed: formatUnits(variableDebt, decimals),
219+
flagged,
220+
};
221+
});
222+
223+
return {
224+
protocol: "aave-v3",
225+
chain,
226+
blockNumber: blockNumber.toString(),
227+
incident: entries.some((e) => e.flagged),
228+
markets: entries,
229+
};
230+
}

src/modules/incidents/schemas.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ const chainEnum = z.enum(SUPPORTED_CHAINS as unknown as [string, ...string[]]);
55

66
export const getMarketIncidentStatusInput = z.object({
77
protocol: z
8-
.enum(["compound-v3"])
8+
.enum(["compound-v3", "aave-v3"])
99
.describe(
10-
"Lending protocol to scan. Currently only compound-v3 is supported. Aave V3 pauses are per-reserve; Morpho Blue has no core-protocol pause."
10+
"Lending protocol to scan. compound-v3 flags per-Comet pause + utilization. aave-v3 flags per-reserve isPaused/isFrozen/!isActive + utilization. Morpho Blue has no core-protocol pause and is not supported."
1111
),
1212
chain: chainEnum
1313
.default("ethereum")

test/market-incident-status.test.ts

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,99 @@ describe("get_market_incident_status (compound-v3)", () => {
123123
expect(cusdt.flagged).toBe(false);
124124
});
125125

126-
it("refuses protocols other than compound-v3", async () => {
126+
it("flags a paused Aave reserve and a high-utilization reserve", async () => {
127+
// Three reserves: WETH (paused, low utilization), USDC (clean, 99% utilization),
128+
// DAI (clean, normal utilization). Expect incident=true with two flagged entries.
129+
const reserves = [
130+
{
131+
underlyingAsset: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
132+
name: "Wrapped Ether",
133+
symbol: "WETH",
134+
decimals: 18n,
135+
isActive: true,
136+
isFrozen: false,
137+
isPaused: true, // flagged: paused
138+
variableBorrowIndex: 10n ** 27n,
139+
availableLiquidity: 1000n * 10n ** 18n,
140+
totalScaledVariableDebt: 100n * 10n ** 18n,
141+
},
142+
{
143+
underlyingAsset: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
144+
name: "USD Coin",
145+
symbol: "USDC",
146+
decimals: 6n,
147+
isActive: true,
148+
isFrozen: false,
149+
isPaused: false,
150+
variableBorrowIndex: 10n ** 27n,
151+
// 1 unit liquid, 99 units borrowed → 99% util → flagged.
152+
availableLiquidity: 1_000_000n,
153+
totalScaledVariableDebt: 99_000_000n,
154+
},
155+
{
156+
underlyingAsset: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
157+
name: "Dai Stablecoin",
158+
symbol: "DAI",
159+
decimals: 18n,
160+
isActive: true,
161+
isFrozen: false,
162+
isPaused: false,
163+
variableBorrowIndex: 10n ** 27n,
164+
availableLiquidity: 500n * 10n ** 18n,
165+
totalScaledVariableDebt: 500n * 10n ** 18n, // 50% util, not flagged
166+
},
167+
];
168+
169+
const mockClient = {
170+
getBlockNumber: vi.fn(async () => 19_800_000n),
171+
readContract: vi.fn(
172+
async ({ functionName }: { functionName: string }) => {
173+
if (functionName === "getReservesData") {
174+
return [
175+
reserves,
176+
{
177+
marketReferenceCurrencyUnit: 10n ** 8n,
178+
marketReferenceCurrencyPriceInUsd: 10n ** 8n,
179+
networkBaseTokenPriceInUsd: 0n,
180+
networkBaseTokenPriceDecimals: 8,
181+
},
182+
];
183+
}
184+
throw new Error(`unexpected readContract ${functionName}`);
185+
}
186+
),
187+
};
188+
189+
vi.doMock("../src/data/rpc.js", () => ({
190+
getClient: () => mockClient,
191+
resetClients: () => {},
192+
}));
193+
194+
const { getMarketIncidentStatus } = await import(
195+
"../src/modules/incidents/index.js"
196+
);
197+
const result = await getMarketIncidentStatus({
198+
protocol: "aave-v3",
199+
chain: "ethereum",
200+
});
201+
202+
expect(result.protocol).toBe("aave-v3");
203+
expect(result.incident).toBe(true);
204+
expect(result.markets).toHaveLength(3);
205+
206+
const weth = result.markets.find((m) => m.symbol === "WETH")!;
207+
expect(weth.isPaused).toBe(true);
208+
expect(weth.flagged).toBe(true);
209+
210+
const usdc = result.markets.find((m) => m.symbol === "USDC")!;
211+
expect(usdc.utilization).toBeGreaterThanOrEqual(0.95);
212+
expect(usdc.flagged).toBe(true);
213+
214+
const dai = result.markets.find((m) => m.symbol === "DAI")!;
215+
expect(dai.flagged).toBe(false);
216+
});
217+
218+
it("refuses unsupported protocols", async () => {
127219
vi.doMock("../src/data/rpc.js", () => ({
128220
getClient: () => ({ getBlockNumber: async () => 0n, multicall: async () => [] }),
129221
resetClients: () => {},
@@ -133,9 +225,9 @@ describe("get_market_incident_status (compound-v3)", () => {
133225
);
134226
await expect(
135227
getMarketIncidentStatus({
136-
protocol: "aave-v3" as unknown as "compound-v3",
228+
protocol: "morpho-blue" as unknown as "compound-v3",
137229
chain: "ethereum",
138230
})
139-
).rejects.toThrow(/compound-v3/);
231+
).rejects.toThrow(/compound-v3|aave-v3/);
140232
});
141233
});

0 commit comments

Comments
 (0)