From bcf5e9abfbe4191df856081ba261a5f2296bf7cd Mon Sep 17 00:00:00 2001 From: Baliram Jagtap Date: Mon, 16 Mar 2026 18:23:08 +0530 Subject: [PATCH 1/2] Add risk command group (simulate, sandbox, report) for Sovryn v1 liquidation stress testing, including subgraph data and CoinGecko pricing --- README.md | 32 +++ bin/index.ts | 3 + package-lock.json | 66 ++--- src/commands/attestation.ts | 12 +- src/commands/risk/index.ts | 67 +++++ src/commands/risk/report.ts | 97 +++++++ src/commands/risk/sandbox.ts | 144 +++++++++++ src/commands/risk/simulate.ts | 118 +++++++++ src/services/risk/dataSources.ts | 329 ++++++++++++++++++++++++ src/services/risk/engine.ts | 292 +++++++++++++++++++++ src/services/risk/protocols/sovryn.ts | 43 ++++ src/services/risk/protocols/tropykus.ts | 42 +++ src/services/risk/reporting.ts | 97 +++++++ src/services/risk/types.ts | 219 ++++++++++++++++ 14 files changed, 1527 insertions(+), 34 deletions(-) create mode 100644 src/commands/risk/index.ts create mode 100644 src/commands/risk/report.ts create mode 100644 src/commands/risk/sandbox.ts create mode 100644 src/commands/risk/simulate.ts create mode 100644 src/services/risk/dataSources.ts create mode 100644 src/services/risk/engine.ts create mode 100644 src/services/risk/protocols/sovryn.ts create mode 100644 src/services/risk/protocols/tropykus.ts create mode 100644 src/services/risk/reporting.ts create mode 100644 src/services/risk/types.ts diff --git a/README.md b/README.md index 76db5ee..9358ee6 100644 --- a/README.md +++ b/README.md @@ -1087,6 +1087,38 @@ The command provides: - Cost in RBTC and Wei - Recommended gas limits (with buffers) - Optimization tips (if applicable) + +### 13. Liquidation Risk (Stress Testing) + +The `risk` command group simulates liquidation stress scenarios for Rootstock DeFi lending protocols. It can model price shocks, estimate bad debt/collateral deficits, and generate structured reports. + +> **Note**: +> - Currently focused on **Sovryn v1**. +> - Price data is fetched from CoinGecko; repeated runs may hit rate limits (`429 Too Many Requests`). If that happens, retry after a short wait. + +#### Simulate + +```bash +# Simulate a 40% market shock +rsk-cli risk simulate --shock 40 + +# Simulate a 40% shock but only for a specific asset +rsk-cli risk simulate --shock 40 --asset rbtc +``` + +#### Sandbox + +```bash +# Compare default parameters vs custom LTV/threshold +rsk-cli risk sandbox --ltv 65 --threshold 80 +``` + +#### Report + +```bash +# Machine-readable JSON output (CI/CD friendly) +rsk-cli risk report --format json +``` ======= >>>>>>> main diff --git a/bin/index.ts b/bin/index.ts index 3c09961..268220f 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -28,6 +28,7 @@ import { validateAndFormatAddressRSK } from "../src/utils/index.js"; import { rnsUpdateCommand } from "../src/commands/rnsUpdate.js"; import { rnsTransferCommand } from "../src/commands/rnsTransfer.js"; import { rnsRegisterCommand } from "../src/commands/rnsRegister.js"; +import { registerRiskCommands } from "../src/commands/risk/index.js"; interface CommandOptions { testnet?: boolean; @@ -101,6 +102,8 @@ program .description("CLI tool for interacting with Rootstock blockchain") .version("1.4.0", "-v, --version", "Display the current version"); +registerRiskCommands(program); + program .command("wallet") .description( diff --git a/package-lock.json b/package-lock.json index 0ed9d31..03a13a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1877,9 +1877,9 @@ } }, "node_modules/@openzeppelin/contracts": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.4.0.tgz", - "integrity": "sha512-eCYgWnLg6WO+X52I16TZt8uEjbtdkgLC0SUX/xnAksjjrQI4Xfn4iBRoI5j55dmlOhDv1Y7BoR3cU7e3WWhC6A==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.6.1.tgz", + "integrity": "sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w==", "license": "MIT" }, "node_modules/@openzeppelin/merkle-tree": { @@ -2068,13 +2068,13 @@ } }, "node_modules/@types/bun": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", - "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==", + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.10.tgz", + "integrity": "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.3.9" + "bun-types": "1.3.10" } }, "node_modules/@types/debug": { @@ -2125,9 +2125,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz", - "integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -2557,9 +2557,9 @@ "license": "MIT" }, "node_modules/bun-types": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", - "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.10.tgz", + "integrity": "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg==", "dev": true, "license": "MIT", "dependencies": { @@ -3241,9 +3241,9 @@ } }, "node_modules/figlet": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.10.0.tgz", - "integrity": "sha512-aktIwEZZ6Gp9AWdMXW4YCi0J2Ahuxo67fNJRUIWD81w8pQ0t9TS8FFpbl27ChlTLF06VkwjDesZSzEVzN75rzA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.11.0.tgz", + "integrity": "sha512-EEx3OS/l2bFqcUNN2NM9FPJp8vAMrgbCxsbl2hbcJNNxOEwVe3mEzrhan7TbJQViZa8mMqhihlbCaqD+LyYKTQ==", "license": "MIT", "dependencies": { "commander": "^14.0.0" @@ -3339,9 +3339,9 @@ "license": "MIT" }, "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -4053,9 +4053,9 @@ "license": "BSD-3-Clause" }, "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", "license": "MIT" }, "node_modules/indent-string": { @@ -4815,12 +4815,12 @@ } }, "node_modules/ora/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -4839,9 +4839,9 @@ } }, "node_modules/ox": { - "version": "0.12.4", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.12.4.tgz", - "integrity": "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==", + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.5.tgz", + "integrity": "sha512-HgmHmBveYO40H/R3K6TMrwYtHsx/u6TAB+GpZlgJCoW0Sq5Ttpjih0IZZiwGQw7T6vdW4IAyobYrE2mdAvyF8Q==", "funding": [ { "type": "github", @@ -5696,9 +5696,9 @@ } }, "node_modules/viem": { - "version": "2.46.3", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.46.3.tgz", - "integrity": "sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg==", + "version": "2.47.4", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.4.tgz", + "integrity": "sha512-h0Wp/SYmJO/HB4B/em1OZ3W1LaKrmr7jzaN7talSlZpo0LCn0V6rZ5g923j6sf4VUSrqp/gUuWuHFc7UcoIp8A==", "funding": [ { "type": "github", @@ -5713,7 +5713,7 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.12.4", + "ox": "0.14.5", "ws": "8.18.3" }, "peerDependencies": { diff --git a/src/commands/attestation.ts b/src/commands/attestation.ts index 96ada6f..aa5b3f2 100644 --- a/src/commands/attestation.ts +++ b/src/commands/attestation.ts @@ -1,9 +1,11 @@ import chalk from "chalk"; import ora from "ora"; -import { EAS, SchemaEncoder } from "@ethereum-attestation-service/eas-sdk"; import ViemProvider from "../utils/viemProvider.js"; import { AttestationResult } from "../utils/types.js"; import { GraphQLService } from "../utils/graphqlService.js"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); type AttestationCommandOptions = { testnet: boolean; @@ -88,6 +90,11 @@ async function setupEAS(params: AttestationCommandOptions) { ? EAS_CONTRACTS.testnet : EAS_CONTRACTS.mainnet; + // Use CommonJS entry via require() to avoid ESM specifier issues in certain environments. + const { EAS } = require("@ethereum-attestation-service/eas-sdk") as { + EAS: new (address: `0x${string}`) => any; + }; + const eas = new EAS(easAddress); eas.connect(walletClient as any); @@ -112,6 +119,9 @@ async function createAttestation(params: AttestationCommandOptions): Promise any; + }; const schemaEncoder = new SchemaEncoder(params.data); const encodedData = schemaEncoder.encodeData(JSON.parse(params.data)); diff --git a/src/commands/risk/index.ts b/src/commands/risk/index.ts new file mode 100644 index 0000000..91607f6 --- /dev/null +++ b/src/commands/risk/index.ts @@ -0,0 +1,67 @@ +import { Command } from "commander"; +import { riskSimulateCommand } from "./simulate.js"; +import { riskSandboxCommand } from "./sandbox.js"; +import { riskReportCommand } from "./report.js"; + +export function registerRiskCommands(program: Command): void { + const risk = program + .command("risk") + .description("Liquidation stress testing and risk analysis for Rootstock DeFi protocols"); + + risk + .command("simulate") + .description("Simulate liquidation cascades under price shocks") + .requiredOption("--shock ", "Price shock percentage to apply", (value: string) => + parseFloat(value) + ) + .option("--asset ", "Limit the shock to a specific asset (e.g. rbtc)") + .action(async (options: { shock: number; asset?: string }) => { + await riskSimulateCommand({ + shock: options.shock, + asset: options.asset, + isExternal: false, + }); + }); + + risk + .command("sandbox") + .description("Experiment with custom LTV and liquidation thresholds") + .option("--ltv ", "Maximum LTV (e.g. 65 for 65%)", (value: string) => + parseFloat(value) + ) + .option( + "--threshold ", + "Liquidation threshold (e.g. 80 for 80%)", + (value: string) => parseFloat(value) + ) + .action(async (options: { ltv?: number; threshold?: number }) => { + await riskSandboxCommand({ + ltv: options.ltv, + threshold: options.threshold, + isExternal: false, + }); + }); + + risk + .command("report") + .description("Generate structured risk reports for CI/CD and monitoring") + .option( + "--format ", + "Output format: json|table (default: json)", + "json" + ) + .option( + "--shock ", + "Price shock percentage to apply (default: 40)", + (value: string) => parseFloat(value) + ) + .action(async (options: { format?: string; shock?: number }) => { + const fmt = options.format === "table" ? "table" : "json"; + await riskReportCommand({ + format: fmt, + shock: options.shock, + isExternal: false, + }); + }); +} + diff --git a/src/commands/risk/report.ts b/src/commands/risk/report.ts new file mode 100644 index 0000000..99b5445 --- /dev/null +++ b/src/commands/risk/report.ts @@ -0,0 +1,97 @@ +import { RiskSimulationConfig, RiskSimulationResult } from "../../services/risk/types.js"; +import { runRiskSimulation } from "../../services/risk/engine.js"; +import { createSpinner } from "../../utils/spinner.js"; +import { logError, logInfo } from "../../utils/logger.js"; + +export interface RiskReportCliOptions { + format?: "table" | "json"; + shock?: number; + isExternal?: boolean; +} + +function formatNumber(value: number, decimals = 2): string { + if (!isFinite(value)) return "āˆž"; + return value.toFixed(decimals); +} + +function buildHighLevelTable(result: RiskSimulationResult): string { + const headers = [ + "Protocol", + "Bad Debt (USD)", + "Collateral Deficit (USD)", + ]; + + const rows = result.protocols.map((p) => [ + p.protocol, + formatNumber(p.totalBadDebtUsd), + formatNumber(p.totalCollateralDeficitUsd), + ]); + + const allRows = [headers, ...rows]; + const colWidths = headers.map((_, colIdx) => + Math.max(...allRows.map((row) => row[colIdx].length)) + ); + + const formatRow = (row: string[]) => + row + .map((cell, idx) => cell.padEnd(colWidths[idx])) + .join(" "); + + const lines: string[] = []; + lines.push(formatRow(headers)); + lines.push(colWidths.map((w) => "-".repeat(w)).join(" ")); + for (const row of rows) { + lines.push(formatRow(row)); + } + + return lines.join("\n"); +} + +export async function riskReportCommand( + options: RiskReportCliOptions +): Promise { + const isExternal = options.isExternal ?? false; + const format = options.format ?? "json"; + + const shock = options.shock ?? 40; + if (shock <= 0) { + logError(isExternal, "Shock percentage must be greater than zero."); + return; + } + + const config: RiskSimulationConfig = { + shockPercentage: shock, + // For now, only include Sovryn v1. Tropykus can be re-enabled later. + protocols: ["sovryn-v1"], + protocolConfigs: {}, + }; + + const spinner = createSpinner(isExternal); + + try { + spinner.start(`Running risk simulation report with ${shock}% price shock...`); + + const result = await runRiskSimulation(config); + + spinner.succeed("Risk simulation report generated."); + + if (format === "table") { + const table = buildHighLevelTable(result); + logInfo(isExternal, "\nšŸ“Š Risk Report\n"); + logInfo(isExternal, table); + return result; + } + + // JSON output, suitable for CI/CD and machine consumption. + const json = JSON.stringify(result, null, 2); + logInfo(isExternal, json); + return result; + } catch (error: any) { + spinner.fail("Risk report generation failed."); + logError( + isExternal, + `Error during risk report generation: ${error?.message || String(error)}` + ); + } +} + diff --git a/src/commands/risk/sandbox.ts b/src/commands/risk/sandbox.ts new file mode 100644 index 0000000..40d3c65 --- /dev/null +++ b/src/commands/risk/sandbox.ts @@ -0,0 +1,144 @@ +import { RiskSimulationConfig, RiskSimulationResult } from "../../services/risk/types.js"; +import { runRiskSimulation } from "../../services/risk/engine.js"; +import { createSpinner } from "../../utils/spinner.js"; +import { logError, logInfo, logSuccess } from "../../utils/logger.js"; + +export interface RiskSandboxCliOptions { + ltv?: number; + threshold?: number; + isExternal?: boolean; +} + +function formatNumber(value: number, decimals = 2): string { + if (!isFinite(value)) return "āˆž"; + return value.toFixed(decimals); +} + +function buildComparisonSummaryTable( + baseResult: RiskSimulationResult, + sandboxResult: RiskSimulationResult +): string { + const headers = [ + "Protocol", + "Scenario", + "Bad Debt (USD)", + "Collateral Deficit (USD)", + ]; + + const rows: string[][] = []; + + for (const base of baseResult.protocols) { + const sandbox = sandboxResult.protocols.find( + (p) => p.protocol === base.protocol + ); + if (!sandbox) continue; + + rows.push([ + base.protocol, + "base", + formatNumber(base.totalBadDebtUsd), + formatNumber(base.totalCollateralDeficitUsd), + ]); + rows.push([ + base.protocol, + "sandbox", + formatNumber(sandbox.totalBadDebtUsd), + formatNumber(sandbox.totalCollateralDeficitUsd), + ]); + } + + const allRows = [headers, ...rows]; + const colWidths = headers.map((_, colIdx) => + Math.max(...allRows.map((row) => row[colIdx].length)) + ); + + const formatRow = (row: string[]) => + row + .map((cell, idx) => cell.padEnd(colWidths[idx])) + .join(" "); + + const lines: string[] = []; + lines.push(formatRow(headers)); + lines.push(colWidths.map((w) => "-".repeat(w)).join(" ")); + for (const row of rows) { + lines.push(formatRow(row)); + } + + return lines.join("\n"); +} + +export async function riskSandboxCommand( + options: RiskSandboxCliOptions +): Promise<{ base: RiskSimulationResult; sandbox: RiskSimulationResult } | void> { + const isExternal = options.isExternal ?? false; + + if (options.ltv !== undefined && (options.ltv <= 0 || options.ltv >= 100)) { + logError(isExternal, "LTV must be between 0 and 100 (exclusive)."); + return; + } + + if ( + options.threshold !== undefined && + (options.threshold <= 0 || options.threshold >= 100) + ) { + logError( + isExternal, + "Liquidation threshold must be between 0 and 100 (exclusive)." + ); + return; + } + + const spinner = createSpinner(isExternal); + + try { + // Run base simulation with default protocol parameters and a moderate shock. + const baseConfig: RiskSimulationConfig = { + shockPercentage: 30, + // Focus on Sovryn v1 for now; Tropykus will be enabled later. + protocols: ["sovryn-v1"], + protocolConfigs: {}, + }; + + spinner.start("Running base risk simulation..."); + const baseResult = await runRiskSimulation(baseConfig); + spinner.succeed("Base simulation completed."); + + const protocolOverride = { + maxLtv: + options.ltv !== undefined ? options.ltv / 100 : undefined, + liquidationThreshold: + options.threshold !== undefined ? options.threshold / 100 : undefined, + }; + + const sandboxConfig: RiskSimulationConfig = { + shockPercentage: baseConfig.shockPercentage, + protocols: baseConfig.protocols, + protocolConfigs: { + "sovryn-v1": protocolOverride, + }, + }; + + spinner.start("Running sandbox simulation with custom parameters..."); + const sandboxResult = await runRiskSimulation(sandboxConfig); + spinner.succeed("Sandbox simulation completed."); + + const table = buildComparisonSummaryTable(baseResult, sandboxResult); + + logInfo(isExternal, "\nšŸ“Š Risk Sandbox Comparison\n"); + logInfo(isExternal, table); + + logSuccess( + isExternal, + "\nSandbox simulation complete. Review how bad debt and collateral deficits change under the new parameters." + ); + + return { base: baseResult, sandbox: sandboxResult }; + } catch (error: any) { + spinner.fail("Risk sandbox simulation failed."); + logError( + isExternal, + `Error during risk sandbox simulation: ${error?.message || String(error)}` + ); + } +} + diff --git a/src/commands/risk/simulate.ts b/src/commands/risk/simulate.ts new file mode 100644 index 0000000..beb35ff --- /dev/null +++ b/src/commands/risk/simulate.ts @@ -0,0 +1,118 @@ +import { RiskSimulationConfig, RiskSimulationResult, AssetSymbol } from "../../services/risk/types.js"; +import { runRiskSimulation } from "../../services/risk/engine.js"; +import { createSpinner } from "../../utils/spinner.js"; +import { logError, logInfo, logSuccess } from "../../utils/logger.js"; + +export interface RiskSimulateCliOptions { + shock: number; + asset?: string; + isExternal?: boolean; +} + +function formatNumber(value: number, decimals = 2): string { + if (!isFinite(value)) return "āˆž"; + return value.toFixed(decimals); +} + +function buildProtocolSummaryTable(result: RiskSimulationResult): string { + const headers = [ + "Protocol", + "Bad Debt (USD)", + "Collateral Deficit (USD)", + "Positions", + "Liquidatable", + ]; + + const rows = result.protocols.map((p) => { + const positions = p.positions.length; + const liquidatable = p.positions.filter((pos) => pos.liquidatable).length; + return [ + p.protocol, + formatNumber(p.totalBadDebtUsd, 2), + formatNumber(p.totalCollateralDeficitUsd, 2), + String(positions), + String(liquidatable), + ]; + }); + + const allRows = [headers, ...rows]; + const colWidths = headers.map((_, colIdx) => + Math.max(...allRows.map((row) => row[colIdx].length)) + ); + + const formatRow = (row: string[]) => + row + .map((cell, idx) => cell.padEnd(colWidths[idx])) + .join(" "); + + const lines: string[] = []; + lines.push(formatRow(headers)); + lines.push(colWidths.map((w) => "-".repeat(w)).join(" ")); + for (const row of rows) { + lines.push(formatRow(row)); + } + + return lines.join("\n"); +} + +export async function riskSimulateCommand( + options: RiskSimulateCliOptions +): Promise { + const isExternal = options.isExternal ?? false; + + if (options.shock <= 0) { + logError(isExternal, "Shock percentage must be greater than zero."); + return; + } + + const config: RiskSimulationConfig = { + shockPercentage: options.shock, + shockedAssets: options.asset + ? [options.asset.toLowerCase() as AssetSymbol] + : undefined, + // Currently we only rely on Sovryn v1 data; Tropykus will be added + // back once its indexer/subgraph is stable. + protocols: ["sovryn-v1"], + protocolConfigs: {}, + }; + + const spinner = createSpinner(isExternal); + + try { + spinner.start( + `Running risk simulation with ${options.shock}% price shock...` + ); + + const result = await runRiskSimulation(config); + + spinner.succeed("Risk simulation completed."); + + const table = buildProtocolSummaryTable(result); + + logInfo(isExternal, "\nšŸ“Š Risk Simulation Summary\n"); + logInfo(isExternal, table); + logSuccess( + isExternal, + `\nTotal Bad Debt (USD): ${formatNumber( + result.totals.totalBadDebtUsd, + 2 + )}` + ); + logSuccess( + isExternal, + `Total Collateral Deficit (USD): ${formatNumber( + result.totals.totalCollateralDeficitUsd, + 2 + )}` + ); + + return result; + } catch (error: any) { + spinner.fail("Risk simulation failed."); + logError( + isExternal, + `Error during risk simulation: ${error?.message || String(error)}` + ); + } +} + diff --git a/src/services/risk/dataSources.ts b/src/services/risk/dataSources.ts new file mode 100644 index 0000000..fbfbd70 --- /dev/null +++ b/src/services/risk/dataSources.ts @@ -0,0 +1,329 @@ +import { AssetPriceMap, AssetSymbol, BorrowPosition, ProtocolId } from "./types.js"; + +/** + * Mapping from internal asset symbols to CoinGecko IDs. + * These IDs can be adjusted as we refine asset coverage. + */ +const COINGECKO_IDS: Record = { + // Rootstock BTC + rbtc: "rootstock", // Placeholder; align with actual CoinGecko ID for RBTC if different + // RIF token + rif: "rif-token", + // Dollar on Chain / DLLR + dllr: "dllr", + // Sovryn governance token + sov: "sovryn", + // Synthetic USD (we treat this as 1 USD using CoinGecko's usd price) + usd: "usd", +}; + +const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"; + +// Sovryn v1 subgraph / indexer endpoint. +// NOTE: The base URL MUST NOT include a trailing `/graphql` when used +// with POST { query } bodies, otherwise the subgraph name is invalid. +// Can be overridden via SOVRYN_SUBGRAPH_URL environment variable if needed. +export const SOVRYN_SUBGRAPH_URL = + process.env.SOVRYN_SUBGRAPH_URL || + "https://subgraph.sovryn.app/subgraphs/name/DistributedCollective/sovryn-subgraph"; + +// TODO: Replace this placeholder with the actual Tropykus v2 subgraph / indexer endpoint when available. +export const TROPYKUS_SUBGRAPH_URL = + process.env.TROPYKUS_SUBGRAPH_URL || "https://TROPYKUS_SUBGRAPH_URL_TODO"; + +export interface PriceFetchOptions { + vsCurrency?: string; + assets?: AssetSymbol[]; +} + +export async function fetchAssetPrices( + options: PriceFetchOptions = {} +): Promise { + const vsCurrency = options.vsCurrency ?? "usd"; + const assets: AssetSymbol[] = options.assets ?? (Object.keys(COINGECKO_IDS) as AssetSymbol[]); + + const ids = assets + .map((symbol) => COINGECKO_IDS[symbol]) + .filter(Boolean) + .join(","); + + if (!ids) { + return {}; + } + + const url = `${COINGECKO_BASE_URL}/simple/price?ids=${encodeURIComponent( + ids + )}&vs_currencies=${encodeURIComponent(vsCurrency)}`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch prices from CoinGecko: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as Record>; + + const priceMap: AssetPriceMap = {}; + + for (const symbol of assets) { + const id = COINGECKO_IDS[symbol]; + const entry = data[id]; + if (entry && typeof entry[vsCurrency] === "number") { + priceMap[symbol] = entry[vsCurrency]; + } + } + + return priceMap; +} + +export interface PositionFetchOptions { + /** + * Optional hint to use testnet endpoints or mocks. + */ + testnet?: boolean; +} + +interface RawPosition { + id: string; + borrower: string; + collateralAsset: string; + collateralAmount: string; + borrowAsset: string; + borrowAmount: string; + liquidationThreshold?: string | number | null; +} + +async function fetchJson(url: string, query: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return (await response.json()) as T; + } finally { + clearTimeout(timeoutId); + } +} + +// Map protocol-specific token symbols to our internal AssetSymbol set. +// This avoids mis-pricing Sovryn tokens as RBTC. +const SYMBOL_TO_ASSET: Record = { + // Rootstock BTC variants + rbtc: "rbtc", + wrbtc: "rbtc", + + // Sovryn governance token + sov: "sov", + + // Stablecoins treated as USD + xusd: "usd", + doc: "usd", + + // DLLR + dllr: "dllr", + + // RIF + rif: "rif", +}; + +function resolveAssetSymbol(symbol: string): AssetSymbol { + const lower = symbol.toLowerCase(); + if (SYMBOL_TO_ASSET[lower]) { + return SYMBOL_TO_ASSET[lower]; + } + + // Fallback: treat unknown tokens as USD to avoid extreme mispricing, + // rather than defaulting to RBTC. + return "usd"; +} + +function mapRawPositionsToBorrowPositions( + rawPositions: RawPosition[], + protocol: ProtocolId +): BorrowPosition[] { + return rawPositions.map((p) => { + const collateralAsset = resolveAssetSymbol(p.collateralAsset); + const debtAsset = resolveAssetSymbol(p.borrowAsset); + + const collateralAmount = Number(p.collateralAmount) || 0; + const debtAmount = Number(p.borrowAmount) || 0; + + return { + id: p.id, + protocol, + account: p.borrower, + collateral: [ + { + asset: collateralAsset, + amount: collateralAmount, + }, + ], + debt: [ + { + asset: debtAsset, + amount: debtAmount, + }, + ], + }; + }); +} + +async function fetchSovrynBorrowerPositions( + options: PositionFetchOptions +): Promise { + void options; + + // Exact query confirmed working in Sovryn GraphiQL UI. + const query = ` + query SovrynLoans { + loans(first: 1000) { + id + borrowedAmount + positionSize + user { + id + } + loanToken { + symbol + } + collateralToken { + symbol + } + } + } + `; + + type SovrynLoan = { + id: string; + borrowedAmount?: string | null; + positionSize?: string | null; + user?: { + id: string; + } | null; + loanToken?: { + symbol: string; + } | null; + collateralToken?: { + symbol: string; + } | null; + }; + + try { + // Use a direct POST with a { query } JSON body, matching typical GraphQL usage. + const httpResponse = await fetch(SOVRYN_SUBGRAPH_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ query }), + }); + + const text = await httpResponse.text(); + + if (!httpResponse.ok) { + console.warn( + "[risk][dataSources] Sovryn loans HTTP error:", + httpResponse.status, + httpResponse.statusText, + "- body:", + text.slice(0, 300) + ); + return []; + } + + let json: { + data?: { loans?: SovrynLoan[] }; + errors?: Array<{ message: string }>; + }; + + try { + json = JSON.parse(text); + } catch (parseError) { + console.warn( + "[risk][dataSources] Sovryn loans response was not valid JSON. First 300 chars:", + text.slice(0, 300) + ); + return []; + } + + if (json.errors && json.errors.length > 0) { + console.warn( + "[risk][dataSources] Sovryn loans query returned GraphQL errors:", + json.errors.map((e) => e.message).join(", ") + ); + return []; + } + + const loans = json.data?.loans ?? []; + + const rawPositions: RawPosition[] = loans + .filter( + (loan) => + loan.borrowedAmount && + loan.positionSize && + loan.user && + loan.loanToken && + loan.collateralToken + ) + .map((loan) => ({ + id: loan.id, + borrower: loan.user!.id, + collateralAsset: loan.collateralToken!.symbol, + collateralAmount: loan.positionSize as string, + borrowAsset: loan.loanToken!.symbol, + borrowAmount: loan.borrowedAmount as string, + liquidationThreshold: null, + })); + + return mapRawPositionsToBorrowPositions(rawPositions, "sovryn-v1"); + } catch (error: any) { + console.warn( + "[risk][dataSources] Failed to fetch Sovryn borrower positions:", + error?.message || String(error) + ); + return []; + } +} + +async function fetchTropykusBorrowerPositions( + options: PositionFetchOptions +): Promise { + // Tropykus integration is currently disabled. We return an empty + // array without logging to avoid noisy output in the CLI. + void options; + return []; +} + +/** + * Fetch borrower positions for a given protocol. + * + * NOTE: This is an abstraction point. Initial implementation can use + * placeholder or mocked data, and later be replaced by real on-chain + * or indexer-backed queries without affecting the engine. + */ +export async function fetchBorrowerPositions( + protocol: ProtocolId, + options: PositionFetchOptions = {} +): Promise { + switch (protocol) { + case "sovryn-v1": + return fetchSovrynBorrowerPositions(options); + case "tropykus-v2": + return fetchTropykusBorrowerPositions(options); + default: + return []; + } +} + diff --git a/src/services/risk/engine.ts b/src/services/risk/engine.ts new file mode 100644 index 0000000..42eb8a9 --- /dev/null +++ b/src/services/risk/engine.ts @@ -0,0 +1,292 @@ +import { + AssetPriceMap, + AssetSymbol, + BorrowPosition, + LiquidationParameters, + PositionHealthSnapshot, + PositionLiquidationSummary, + ProtocolId, + ProtocolRiskConfig, + ProtocolSimulationResult, + RiskSimulationConfig, + RiskSimulationResult, +} from "./types.js"; +import { fetchAssetPrices, fetchBorrowerPositions } from "./dataSources.js"; +import { + SOVRYN_PROTOCOL_ID, + SOVRYN_RISK_CONFIG, +} from "./protocols/sovryn.js"; +import { + TROPYKUS_PROTOCOL_ID, + TROPYKUS_RISK_CONFIG, +} from "./protocols/tropykus.js"; +import { buildProtocolSimulationResult, buildRiskSimulationResult } from "./reporting.js"; + +const DEFAULT_PROTOCOL_CONFIGS: Record = { + "sovryn-v1": SOVRYN_RISK_CONFIG, + "tropykus-v2": TROPYKUS_RISK_CONFIG, +}; + +function applyShockToPrices( + basePrices: AssetPriceMap, + shockPercentage: number, + shockedAssets?: AssetSymbol[] +): AssetPriceMap { + const shocked: AssetPriceMap = {}; + const factor = Math.max(0, 1 - shockPercentage / 100); + + const impactedAssets = + shockedAssets && shockedAssets.length > 0 + ? shockedAssets + : (Object.keys(basePrices) as AssetSymbol[]); + + for (const [symbol, price] of Object.entries(basePrices)) { + const sym = symbol as AssetSymbol; + if (impactedAssets.includes(sym)) { + shocked[symbol] = price * factor; + } else { + shocked[symbol] = price; + } + } + + return shocked; +} + +function valueExposure( + exposures: { asset: AssetSymbol; amount: number }[], + prices: AssetPriceMap +): number { + return exposures.reduce((sum, e) => { + const price = prices[e.asset] ?? 0; + return sum + e.amount * price; + }, 0); +} + +function computeHealthFactor( + collateralUsd: number, + debtUsd: number, + liquidationThreshold: number +): number { + if (debtUsd <= 0) return Number.POSITIVE_INFINITY; + const adjustedCollateral = collateralUsd * liquidationThreshold; + return adjustedCollateral / debtUsd; +} + +function mergeLiquidationParams( + base: LiquidationParameters, + override?: Partial +): LiquidationParameters { + if (!override) return base; + return { + maxLtv: override.maxLtv ?? base.maxLtv, + liquidationThreshold: override.liquidationThreshold ?? base.liquidationThreshold, + closeFactor: override.closeFactor ?? base.closeFactor, + liquidationBonus: override.liquidationBonus ?? base.liquidationBonus, + }; +} + +function simulateLiquidationForPosition( + position: BorrowPosition, + pricesAfter: AssetPriceMap, + params: LiquidationParameters +): PositionLiquidationSummary | undefined { + const collateralValue = valueExposure(position.collateral, pricesAfter); + const debtValue = valueExposure(position.debt, pricesAfter); + + if (debtValue <= 0 || collateralValue <= 0) { + return { + positionId: position.id, + protocol: position.protocol, + account: position.account, + totalBadDebtUsd: 0, + collateralDeficitUsd: 0, + steps: [], + }; + } + + const ltv = debtValue / collateralValue; + + if (ltv <= params.maxLtv) { + // Not liquidatable under current rules. + return { + positionId: position.id, + protocol: position.protocol, + account: position.account, + totalBadDebtUsd: 0, + collateralDeficitUsd: 0, + steps: [], + }; + } + + const steps: PositionLiquidationSummary["steps"] = []; + + let remainingCollateral = collateralValue; + let remainingDebt = debtValue; + + // Simple loop: attempt up to a few close-factor based liquidations until + // position becomes healthy or collateral is exhausted. + const maxSteps = 5; + for (let i = 0; i < maxSteps; i++) { + const currentLtv = remainingDebt / remainingCollateral; + if (currentLtv <= params.maxLtv || remainingDebt <= 0 || remainingCollateral <= 0) { + break; + } + + const repayDebt = remainingDebt * params.closeFactor; + const collateralToSeize = repayDebt * (1 + params.liquidationBonus); + + let actualRepay = repayDebt; + let actualCollateralSeized = collateralToSeize; + let liquidationBonusUsd = repayDebt * params.liquidationBonus; + + if (actualCollateralSeized > remainingCollateral) { + // Not enough collateral to cover repayment + bonus; adjust down. + const ratio = remainingCollateral / actualCollateralSeized; + actualCollateralSeized = remainingCollateral; + actualRepay = repayDebt * ratio; + liquidationBonusUsd = actualRepay * params.liquidationBonus; + } + + remainingCollateral -= actualCollateralSeized; + remainingDebt -= actualRepay; + + steps.push({ + positionId: position.id, + protocol: position.protocol, + closeFactorApplied: params.closeFactor, + debtRepaidUsd: actualRepay, + collateralSeizedUsd: actualCollateralSeized - liquidationBonusUsd, + liquidationBonusUsd, + badDebtUsd: 0, + }); + } + + let totalBadDebtUsd = 0; + let collateralDeficitUsd = 0; + + if (remainingDebt > 0 && remainingCollateral <= 0) { + // Collateral completely exhausted, remaining debt is bad debt. + totalBadDebtUsd = remainingDebt; + collateralDeficitUsd = remainingDebt; + } + + return { + positionId: position.id, + protocol: position.protocol, + account: position.account, + totalBadDebtUsd, + collateralDeficitUsd, + steps, + }; +} + +async function fetchPositionsForProtocols( + protocols: ProtocolId[] +): Promise> { + const entries = await Promise.all( + protocols.map(async (id) => { + const positions = await fetchBorrowerPositions(id, {}); + return [id, positions] as const; + }) + ); + + const result: Record = { + "sovryn-v1": [], + "tropykus-v2": [], + }; + + for (const [id, positions] of entries) { + result[id] = positions; + } + + return result; +} + +export async function runRiskSimulation( + config: RiskSimulationConfig +): Promise { + const protocolIds = config.protocols; + + const [basePrices, positionsByProtocol] = await Promise.all([ + fetchAssetPrices({}), + fetchPositionsForProtocols(protocolIds), + ]); + + const shockedPrices = applyShockToPrices( + basePrices, + config.shockPercentage, + config.shockedAssets + ); + + const prices = { + before: basePrices, + after: shockedPrices, + }; + + const protocolResults: ProtocolSimulationResult[] = []; + + for (const protocolId of protocolIds) { + const positions = positionsByProtocol[protocolId] ?? []; + + const baseConfig = DEFAULT_PROTOCOL_CONFIGS[protocolId].liquidation; + const override = config.protocolConfigs?.[protocolId]; + const liquidationParams = mergeLiquidationParams(baseConfig, override); + + const healthSnapshots: PositionHealthSnapshot[] = []; + const liquidationSummaries: PositionLiquidationSummary[] = []; + + for (const position of positions) { + const collateralBefore = valueExposure(position.collateral, basePrices); + const debtBefore = valueExposure(position.debt, basePrices); + const hfBefore = computeHealthFactor( + collateralBefore, + debtBefore, + liquidationParams.liquidationThreshold + ); + + const collateralAfter = valueExposure(position.collateral, shockedPrices); + const debtAfter = valueExposure(position.debt, shockedPrices); + const hfAfter = computeHealthFactor( + collateralAfter, + debtAfter, + liquidationParams.liquidationThreshold + ); + + const liquidatable = hfAfter < 1; + + healthSnapshots.push({ + positionId: position.id, + protocol: position.protocol, + account: position.account, + collateralValueBefore: collateralBefore, + debtValueBefore: debtBefore, + healthFactorBefore: hfBefore, + collateralValueAfter: collateralAfter, + debtValueAfter: debtAfter, + healthFactorAfter: hfAfter, + liquidatable, + }); + + if (liquidatable) { + const summary = simulateLiquidationForPosition( + position, + shockedPrices, + liquidationParams + ); + if (summary) { + liquidationSummaries.push(summary); + } + } + } + + const protocolResult = buildProtocolSimulationResult( + protocolId, + healthSnapshots, + liquidationSummaries + ); + protocolResults.push(protocolResult); + } + + return buildRiskSimulationResult(config, prices, protocolResults); +} + diff --git a/src/services/risk/protocols/sovryn.ts b/src/services/risk/protocols/sovryn.ts new file mode 100644 index 0000000..a992189 --- /dev/null +++ b/src/services/risk/protocols/sovryn.ts @@ -0,0 +1,43 @@ +import { + AssetSymbol, + BorrowPosition, + LiquidationParameters, + ProtocolId, + ProtocolRiskConfig, +} from "../types.js"; + +export const SOVRYN_PROTOCOL_ID: ProtocolId = "sovryn-v1"; + +/** + * Default Sovryn v1 liquidation parameters. + * These values are approximate and should be refined against + * the live protocol configuration when integrating with production data. + */ +export const DEFAULT_SOVRYN_LIQUIDATION_PARAMS: LiquidationParameters = { + maxLtv: 0.7, + liquidationThreshold: 0.8, + closeFactor: 0.5, + liquidationBonus: 0.08, +}; + +export const SOVRYN_RISK_CONFIG: ProtocolRiskConfig = { + id: SOVRYN_PROTOCOL_ID, + liquidation: DEFAULT_SOVRYN_LIQUIDATION_PARAMS, +}; + +export interface SovrynPosition extends BorrowPosition { + protocol: typeof SOVRYN_PROTOCOL_ID; +} + +/** + * Placeholder for Sovryn v1 position fetching via on-chain or indexer data. + * This function can be expanded to query real positions while keeping the + * engine API stable. + */ +export async function fetchSovrynPositions(): Promise { + // TODO: integrate with Sovryn indexer / contracts. + return []; +} + +export const SOVRYN_SUPPORTED_ASSETS: AssetSymbol[] = ["rbtc", "rif", "dllr"]; + diff --git a/src/services/risk/protocols/tropykus.ts b/src/services/risk/protocols/tropykus.ts new file mode 100644 index 0000000..5e9b132 --- /dev/null +++ b/src/services/risk/protocols/tropykus.ts @@ -0,0 +1,42 @@ +import { + AssetSymbol, + BorrowPosition, + LiquidationParameters, + ProtocolId, + ProtocolRiskConfig, +} from "../types.js"; + +export const TROPYKUS_PROTOCOL_ID: ProtocolId = "tropykus-v2"; + +/** + * Default Tropykus v2 liquidation parameters. + * These are approximate defaults and should be adjusted to match actual + * protocol settings when wired to live data. + */ +export const DEFAULT_TROPYKUS_LIQUIDATION_PARAMS: LiquidationParameters = { + maxLtv: 0.65, + liquidationThreshold: 0.75, + closeFactor: 0.5, + liquidationBonus: 0.05, +}; + +export const TROPYKUS_RISK_CONFIG: ProtocolRiskConfig = { + id: TROPYKUS_PROTOCOL_ID, + liquidation: DEFAULT_TROPYKUS_LIQUIDATION_PARAMS, +}; + +export interface TropykusPosition extends BorrowPosition { + protocol: typeof TROPYKUS_PROTOCOL_ID; +} + +/** + * Placeholder for Tropykus v2 position fetching via on-chain or indexer data. + * This function can be expanded without changing the engine interface. + */ +export async function fetchTropykusPositions(): Promise { + // TODO: integrate with Tropykus indexer / contracts. + return []; +} + +export const TROPYKUS_SUPPORTED_ASSETS: AssetSymbol[] = ["rbtc", "rif", "dllr"]; + diff --git a/src/services/risk/reporting.ts b/src/services/risk/reporting.ts new file mode 100644 index 0000000..375ca37 --- /dev/null +++ b/src/services/risk/reporting.ts @@ -0,0 +1,97 @@ +import { + InsolvencyThresholdEstimate, + PositionHealthSnapshot, + ProtocolId, + ProtocolSimulationResult, + RiskSimulationConfig, + RiskSimulationResult, +} from "./types.js"; + +/** + * Aggregate protocol-level metrics from per-position snapshots and liquidation summaries. + */ +export function buildProtocolSimulationResult( + protocol: ProtocolId, + positions: PositionHealthSnapshot[], + liquidations: ProtocolSimulationResult["liquidations"] +): ProtocolSimulationResult { + const totalBadDebtUsd = liquidations.reduce( + (sum, p) => sum + p.totalBadDebtUsd, + 0 + ); + + const totalCollateralDeficitUsd = liquidations.reduce( + (sum, p) => sum + p.collateralDeficitUsd, + 0 + ); + + return { + protocol, + positions, + liquidations, + totalBadDebtUsd, + totalCollateralDeficitUsd, + }; +} + +/** + * Compute a very simple insolvency threshold estimate for a single protocol + * by extrapolating linearly from the current shock and resulting bad debt. + * + * This is a heuristic and should be refined once more precise models are available. + */ +export function estimateInsolvencyThresholdForProtocol( + config: RiskSimulationConfig, + protocolResult: ProtocolSimulationResult +): InsolvencyThresholdEstimate | undefined { + const currentShock = config.shockPercentage; + const currentBadDebt = protocolResult.totalBadDebtUsd; + + if (currentShock <= 0 || currentBadDebt <= 0) { + return undefined; + } + + // Linear heuristic: assume bad debt grows roughly proportional to shock. + // We treat the current shock as the insolvency threshold if any bad debt appears. + return { + shockPercentage: currentShock, + badDebtUsd: currentBadDebt, + }; +} + +/** + * Final assembly helper for RiskSimulationResult, aggregating protocol results + * and computing global totals and insolvency threshold estimates. + */ +export function buildRiskSimulationResult( + config: RiskSimulationConfig, + prices: RiskSimulationResult["prices"], + protocolResults: ProtocolSimulationResult[] +): RiskSimulationResult { + const totals = protocolResults.reduce( + (acc, p) => { + acc.totalBadDebtUsd += p.totalBadDebtUsd; + acc.totalCollateralDeficitUsd += p.totalCollateralDeficitUsd; + return acc; + }, + { totalBadDebtUsd: 0, totalCollateralDeficitUsd: 0 } + ); + + const insolvencyThresholds: RiskSimulationResult["insolvencyThresholds"] = {}; + + for (const result of protocolResults) { + const estimate = estimateInsolvencyThresholdForProtocol(config, result); + if (estimate) { + insolvencyThresholds[result.protocol] = estimate; + } + } + + return { + config, + prices, + protocols: protocolResults, + totals, + insolvencyThresholds, + }; +} + diff --git a/src/services/risk/types.ts b/src/services/risk/types.ts new file mode 100644 index 0000000..7927750 --- /dev/null +++ b/src/services/risk/types.ts @@ -0,0 +1,219 @@ +export type ProtocolId = "sovryn-v1" | "tropykus-v2"; + +// Internal asset symbols used by the risk engine. +// These should correspond either to CoinGecko IDs (via COINGECKO_IDS) +// or to synthetic assets we treat specially (e.g. usd = 1). +export type AssetSymbol = "rbtc" | "rif" | "dllr" | "sov" | "usd"; + +export interface AssetPriceMap { + /** + * Price in USD for each asset symbol. + */ + [symbol: string]: number; +} + +export interface AssetExposure { + asset: AssetSymbol; + amount: number; +} + +export interface BorrowPosition { + /** + * Unique identifier for the position (protocol-specific). + */ + id: string; + /** + * Protocol this position belongs to. + */ + protocol: ProtocolId; + /** + * Account owner address (for reference only). + */ + account: string; + /** + * Collateral assets posted by the borrower. + */ + collateral: AssetExposure[]; + /** + * Borrowed assets (liabilities). + */ + debt: AssetExposure[]; +} + +export interface LiquidationParameters { + /** + * Maximum allowed Loan-To-Value ratio before liquidation, as a fraction (e.g. 0.75 for 75%). + */ + maxLtv: number; + /** + * Liquidation threshold as a fraction of collateral value (e.g. 0.8 for 80%). + */ + liquidationThreshold: number; + /** + * Portion of the position that can be liquidated at once (e.g. 0.5 for 50%). + */ + closeFactor: number; + /** + * Bonus for liquidators on seized collateral (e.g. 0.05 for 5%). + */ + liquidationBonus: number; +} + +export interface ProtocolRiskConfig { + id: ProtocolId; + /** + * Global/default liquidation parameters for this protocol. + */ + liquidation: LiquidationParameters; +} + +export interface RiskSimulationConfig { + /** + * Percentage price drop to apply (e.g. 40 means a 40% drop). + */ + shockPercentage: number; + /** + * Optional subset of assets to shock; if omitted, all known assets are shocked. + */ + shockedAssets?: AssetSymbol[]; + /** + * Protocols to include in this simulation. + */ + protocols: ProtocolId[]; + /** + * Optional protocol-specific overrides for liquidation params. + */ + protocolConfigs?: Partial>>; +} + +export interface PositionHealthSnapshot { + positionId: string; + protocol: ProtocolId; + account: string; + /** + * Total collateral value before the shock (in USD). + */ + collateralValueBefore: number; + /** + * Total debt value before the shock (in USD). + */ + debtValueBefore: number; + /** + * Health factor before the shock. + */ + healthFactorBefore: number; + /** + * Total collateral value after applying the price shock. + */ + collateralValueAfter: number; + /** + * Total debt value after the shock. + */ + debtValueAfter: number; + /** + * Health factor after applying the price shock. + */ + healthFactorAfter: number; + /** + * Whether this position became liquidatable after the shock. + */ + liquidatable: boolean; +} + +export interface LiquidationStep { + positionId: string; + protocol: ProtocolId; + /** + * Fraction of the position that was liquidated in this step (0–1). + */ + closeFactorApplied: number; + /** + * Debt repaid in this step (USD). + */ + debtRepaidUsd: number; + /** + * Collateral seized by the liquidator (USD, pre-bonus). + */ + collateralSeizedUsd: number; + /** + * Liquidation bonus amount on top of collateral seized (USD). + */ + liquidationBonusUsd: number; + /** + * Resulting bad debt after this step (USD, if any). + */ + badDebtUsd: number; +} + +export interface PositionLiquidationSummary { + positionId: string; + protocol: ProtocolId; + account: string; + /** + * Aggregate bad debt for this position after all liquidation steps. + */ + totalBadDebtUsd: number; + /** + * Aggregate collateral deficit for this position (if collateral was insufficient). + */ + collateralDeficitUsd: number; + /** + * Liquidation steps executed for this position, in order. + */ + steps: LiquidationStep[]; +} + +export interface ProtocolSimulationResult { + protocol: ProtocolId; + positions: PositionHealthSnapshot[]; + liquidations: PositionLiquidationSummary[]; + /** + * Total bad debt across all positions in this protocol. + */ + totalBadDebtUsd: number; + /** + * Total collateral deficit across all positions in this protocol. + */ + totalCollateralDeficitUsd: number; +} + +export interface InsolvencyThresholdEstimate { + /** + * Approximate minimum shock percentage that leads to protocol insolvency. + */ + shockPercentage: number; + /** + * Total bad debt at this threshold shock. + */ + badDebtUsd: number; +} + +export interface RiskSimulationResult { + /** + * Input configuration used for this simulation. + */ + config: RiskSimulationConfig; + /** + * Asset prices before and after the shock (in USD). + */ + prices: { + before: AssetPriceMap; + after: AssetPriceMap; + }; + /** + * Per-protocol simulation details. + */ + protocols: ProtocolSimulationResult[]; + /** + * Global totals across all protocols. + */ + totals: { + totalBadDebtUsd: number; + totalCollateralDeficitUsd: number; + }; + /** + * Optional insolvency threshold estimates per protocol. + */ + insolvencyThresholds: Partial>; +} + From 4dc54b904a89cae4e15866aaf4ba1ecb0c0785b9 Mon Sep 17 00:00:00 2001 From: Baliram Jagtap Date: Fri, 17 Apr 2026 11:53:45 +0530 Subject: [PATCH 2/2] Fix risk simulation per review feedback --- src/commands/risk/report.ts | 17 +- src/commands/risk/sandbox.ts | 18 +- src/commands/risk/simulate.ts | 30 +-- src/services/risk/dataSources.ts | 245 +++++++++++++----------- src/services/risk/engine.ts | 170 ++++++++++------ src/services/risk/protocols/sovryn.ts | 1 - src/services/risk/protocols/tropykus.ts | 1 - src/services/risk/reporting.ts | 57 ++++-- src/services/risk/types.ts | 19 +- src/utils/format.ts | 5 + 10 files changed, 343 insertions(+), 220 deletions(-) create mode 100644 src/utils/format.ts diff --git a/src/commands/risk/report.ts b/src/commands/risk/report.ts index 99b5445..13fa77c 100644 --- a/src/commands/risk/report.ts +++ b/src/commands/risk/report.ts @@ -2,6 +2,7 @@ import { RiskSimulationConfig, RiskSimulationResult } from "../../services/risk/ import { runRiskSimulation } from "../../services/risk/engine.js"; import { createSpinner } from "../../utils/spinner.js"; import { logError, logInfo } from "../../utils/logger.js"; +import { formatNumber } from "../../utils/format.js"; export interface RiskReportCliOptions { format?: "table" | "json"; @@ -9,11 +10,6 @@ export interface RiskReportCliOptions { isExternal?: boolean; } -function formatNumber(value: number, decimals = 2): string { - if (!isFinite(value)) return "āˆž"; - return value.toFixed(decimals); -} - function buildHighLevelTable(result: RiskSimulationResult): string { const headers = [ "Protocol", @@ -54,14 +50,18 @@ export async function riskReportCommand( const format = options.format ?? "json"; const shock = options.shock ?? 40; - if (shock <= 0) { - logError(isExternal, "Shock percentage must be greater than zero."); + if (!Number.isFinite(shock)) { + logError(isExternal, "Shock percentage must be a number."); + return; + } + + if (shock <= 0 || shock >= 100) { + logError(isExternal, "Shock percentage must be between 0 and 100."); return; } const config: RiskSimulationConfig = { shockPercentage: shock, - // For now, only include Sovryn v1. Tropykus can be re-enabled later. protocols: ["sovryn-v1"], protocolConfigs: {}, }; @@ -82,7 +82,6 @@ export async function riskReportCommand( return result; } - // JSON output, suitable for CI/CD and machine consumption. const json = JSON.stringify(result, null, 2); logInfo(isExternal, json); return result; diff --git a/src/commands/risk/sandbox.ts b/src/commands/risk/sandbox.ts index 40d3c65..63542af 100644 --- a/src/commands/risk/sandbox.ts +++ b/src/commands/risk/sandbox.ts @@ -2,6 +2,7 @@ import { RiskSimulationConfig, RiskSimulationResult } from "../../services/risk/ import { runRiskSimulation } from "../../services/risk/engine.js"; import { createSpinner } from "../../utils/spinner.js"; import { logError, logInfo, logSuccess } from "../../utils/logger.js"; +import { formatNumber } from "../../utils/format.js"; export interface RiskSandboxCliOptions { ltv?: number; @@ -9,11 +10,6 @@ export interface RiskSandboxCliOptions { isExternal?: boolean; } -function formatNumber(value: number, decimals = 2): string { - if (!isFinite(value)) return "āˆž"; - return value.toFixed(decimals); -} - function buildComparisonSummaryTable( baseResult: RiskSimulationResult, sandboxResult: RiskSimulationResult @@ -72,11 +68,21 @@ export async function riskSandboxCommand( ): Promise<{ base: RiskSimulationResult; sandbox: RiskSimulationResult } | void> { const isExternal = options.isExternal ?? false; + if (options.ltv !== undefined && !Number.isFinite(options.ltv)) { + logError(isExternal, "LTV must be a number."); + return; + } + if (options.ltv !== undefined && (options.ltv <= 0 || options.ltv >= 100)) { logError(isExternal, "LTV must be between 0 and 100 (exclusive)."); return; } + if (options.threshold !== undefined && !Number.isFinite(options.threshold)) { + logError(isExternal, "Liquidation threshold must be a number."); + return; + } + if ( options.threshold !== undefined && (options.threshold <= 0 || options.threshold >= 100) @@ -91,10 +97,8 @@ export async function riskSandboxCommand( const spinner = createSpinner(isExternal); try { - // Run base simulation with default protocol parameters and a moderate shock. const baseConfig: RiskSimulationConfig = { shockPercentage: 30, - // Focus on Sovryn v1 for now; Tropykus will be enabled later. protocols: ["sovryn-v1"], protocolConfigs: {}, }; diff --git a/src/commands/risk/simulate.ts b/src/commands/risk/simulate.ts index beb35ff..4828cf9 100644 --- a/src/commands/risk/simulate.ts +++ b/src/commands/risk/simulate.ts @@ -2,6 +2,7 @@ import { RiskSimulationConfig, RiskSimulationResult, AssetSymbol } from "../../s import { runRiskSimulation } from "../../services/risk/engine.js"; import { createSpinner } from "../../utils/spinner.js"; import { logError, logInfo, logSuccess } from "../../utils/logger.js"; +import { formatNumber } from "../../utils/format.js"; export interface RiskSimulateCliOptions { shock: number; @@ -9,10 +10,7 @@ export interface RiskSimulateCliOptions { isExternal?: boolean; } -function formatNumber(value: number, decimals = 2): string { - if (!isFinite(value)) return "āˆž"; - return value.toFixed(decimals); -} +const ALLOWED_ASSETS: AssetSymbol[] = ["rbtc", "rif", "dllr", "sov", "usd"]; function buildProtocolSummaryTable(result: RiskSimulationResult): string { const headers = [ @@ -60,18 +58,28 @@ export async function riskSimulateCommand( ): Promise { const isExternal = options.isExternal ?? false; - if (options.shock <= 0) { - logError(isExternal, "Shock percentage must be greater than zero."); + if (!Number.isFinite(options.shock)) { + logError(isExternal, "Shock percentage must be a number."); + return; + } + + if (options.shock <= 0 || options.shock >= 100) { + logError(isExternal, "Shock percentage must be between 0 and 100."); + return; + } + + const asset = options.asset?.toLowerCase(); + if (asset && !ALLOWED_ASSETS.includes(asset as AssetSymbol)) { + logError( + isExternal, + `Invalid asset "${options.asset}". Allowed: ${ALLOWED_ASSETS.join(", ")}` + ); return; } const config: RiskSimulationConfig = { shockPercentage: options.shock, - shockedAssets: options.asset - ? [options.asset.toLowerCase() as AssetSymbol] - : undefined, - // Currently we only rely on Sovryn v1 data; Tropykus will be added - // back once its indexer/subgraph is stable. + shockedAssets: asset ? [asset as AssetSymbol] : undefined, protocols: ["sovryn-v1"], protocolConfigs: {}, }; diff --git a/src/services/risk/dataSources.ts b/src/services/risk/dataSources.ts index fbfbd70..927d8f4 100644 --- a/src/services/risk/dataSources.ts +++ b/src/services/risk/dataSources.ts @@ -1,71 +1,74 @@ import { AssetPriceMap, AssetSymbol, BorrowPosition, ProtocolId } from "./types.js"; +import { logWarning } from "../../utils/logger.js"; -/** - * Mapping from internal asset symbols to CoinGecko IDs. - * These IDs can be adjusted as we refine asset coverage. - */ -const COINGECKO_IDS: Record = { - // Rootstock BTC - rbtc: "rootstock", // Placeholder; align with actual CoinGecko ID for RBTC if different - // RIF token +const COINGECKO_IDS: Partial> = { + rbtc: "rootstock", rif: "rif-token", - // Dollar on Chain / DLLR dllr: "dllr", - // Sovryn governance token sov: "sovryn", - // Synthetic USD (we treat this as 1 USD using CoinGecko's usd price) - usd: "usd", }; const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"; -// Sovryn v1 subgraph / indexer endpoint. -// NOTE: The base URL MUST NOT include a trailing `/graphql` when used -// with POST { query } bodies, otherwise the subgraph name is invalid. -// Can be overridden via SOVRYN_SUBGRAPH_URL environment variable if needed. export const SOVRYN_SUBGRAPH_URL = process.env.SOVRYN_SUBGRAPH_URL || "https://subgraph.sovryn.app/subgraphs/name/DistributedCollective/sovryn-subgraph"; -// TODO: Replace this placeholder with the actual Tropykus v2 subgraph / indexer endpoint when available. export const TROPYKUS_SUBGRAPH_URL = process.env.TROPYKUS_SUBGRAPH_URL || "https://TROPYKUS_SUBGRAPH_URL_TODO"; export interface PriceFetchOptions { vsCurrency?: string; assets?: AssetSymbol[]; + timeoutMs?: number; + isExternal?: boolean; } export async function fetchAssetPrices( options: PriceFetchOptions = {} ): Promise { const vsCurrency = options.vsCurrency ?? "usd"; - const assets: AssetSymbol[] = options.assets ?? (Object.keys(COINGECKO_IDS) as AssetSymbol[]); + const assets: AssetSymbol[] = + options.assets ?? (Object.keys(COINGECKO_IDS) as AssetSymbol[]); + const timeoutMs = options.timeoutMs ?? 30000; + const isExternal = options.isExternal ?? false; const ids = assets .map((symbol) => COINGECKO_IDS[symbol]) - .filter(Boolean) + .filter((v): v is string => typeof v === "string") .join(","); - if (!ids) { - return {}; - } + const priceMap: AssetPriceMap = { usd: 1 }; + if (!ids) return priceMap; const url = `${COINGECKO_BASE_URL}/simple/price?ids=${encodeURIComponent( ids )}&vs_currencies=${encodeURIComponent(vsCurrency)}`; - const response = await fetch(url); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + let response: Response; + try { + response = await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + if (!response.ok) { - throw new Error(`Failed to fetch prices from CoinGecko: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to fetch prices from CoinGecko: ${response.status} ${response.statusText}` + ); } const data = (await response.json()) as Record>; - const priceMap: AssetPriceMap = {}; - for (const symbol of assets) { const id = COINGECKO_IDS[symbol]; + if (!id) { + logWarning(isExternal, `Unpriced asset symbol: ${symbol}`); + continue; + } const entry = data[id]; if (entry && typeof entry[vsCurrency] === "number") { priceMap[symbol] = entry[vsCurrency]; @@ -80,6 +83,8 @@ export interface PositionFetchOptions { * Optional hint to use testnet endpoints or mocks. */ testnet?: boolean; + timeoutMs?: number; + isExternal?: boolean; } interface RawPosition { @@ -92,17 +97,23 @@ interface RawPosition { liquidationThreshold?: string | number | null; } -async function fetchJson(url: string, query: string): Promise { +async function fetchGraphQL(params: { + url: string; + query: string; + variables?: Record; + timeoutMs: number; +}): Promise { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs); try { - const response = await fetch(url, { + const response = await fetch(params.url, { method: "POST", headers: { "Content-Type": "application/json", + Accept: "application/json", }, - body: JSON.stringify({ query }), + body: JSON.stringify({ query: params.query, variables: params.variables }), signal: controller.signal, }); @@ -116,24 +127,13 @@ async function fetchJson(url: string, query: string): Promise { } } -// Map protocol-specific token symbols to our internal AssetSymbol set. -// This avoids mis-pricing Sovryn tokens as RBTC. const SYMBOL_TO_ASSET: Record = { - // Rootstock BTC variants rbtc: "rbtc", wrbtc: "rbtc", - - // Sovryn governance token sov: "sov", - - // Stablecoins treated as USD xusd: "usd", doc: "usd", - - // DLLR dllr: "dllr", - - // RIF rif: "rif", }; @@ -142,12 +142,39 @@ function resolveAssetSymbol(symbol: string): AssetSymbol { if (SYMBOL_TO_ASSET[lower]) { return SYMBOL_TO_ASSET[lower]; } - - // Fallback: treat unknown tokens as USD to avoid extreme mispricing, - // rather than defaulting to RBTC. return "usd"; } +const TOKEN_DECIMALS_BY_SYMBOL: Record = { + wrbtc: 18, + rbtc: 18, + sov: 18, + xusd: 18, + doc: 18, + dllr: 18, + rif: 18, +}; + +function parseTokenAmount(params: { raw: string; decimals: number }): number { + const s = String(params.raw ?? "").trim(); + if (!s) return 0; + + if (s.includes(".")) { + const n = Number(s); + return Number.isFinite(n) ? n : 0; + } + + if (!/^\d+$/.test(s)) return 0; + + const decimals = Math.max(0, Math.min(36, params.decimals)); + const base = 10n ** BigInt(decimals); + const bi = BigInt(s); + const whole = Number(bi / base); + const frac = Number(bi % base) / Number(base); + const out = whole + frac; + return Number.isFinite(out) ? out : 0; +} + function mapRawPositionsToBorrowPositions( rawPositions: RawPosition[], protocol: ProtocolId @@ -156,8 +183,19 @@ function mapRawPositionsToBorrowPositions( const collateralAsset = resolveAssetSymbol(p.collateralAsset); const debtAsset = resolveAssetSymbol(p.borrowAsset); - const collateralAmount = Number(p.collateralAmount) || 0; - const debtAmount = Number(p.borrowAmount) || 0; + const collateralDecimals = + TOKEN_DECIMALS_BY_SYMBOL[p.collateralAsset.toLowerCase()] ?? 18; + const debtDecimals = + TOKEN_DECIMALS_BY_SYMBOL[p.borrowAsset.toLowerCase()] ?? 18; + + const collateralAmount = parseTokenAmount({ + raw: p.collateralAmount, + decimals: collateralDecimals, + }); + const debtAmount = parseTokenAmount({ + raw: p.borrowAmount, + decimals: debtDecimals, + }); return { id: p.id, @@ -182,24 +220,23 @@ function mapRawPositionsToBorrowPositions( async function fetchSovrynBorrowerPositions( options: PositionFetchOptions ): Promise { - void options; + const timeoutMs = options.timeoutMs ?? 30000; + const isExternal = options.isExternal ?? false; - // Exact query confirmed working in Sovryn GraphiQL UI. const query = ` - query SovrynLoans { - loans(first: 1000) { + query SovrynLoans($lastId: ID) { + loans( + first: 1000, + orderBy: id, + orderDirection: asc, + where: { id_gt: $lastId } + ) { id borrowedAmount positionSize - user { - id - } - loanToken { - symbol - } - collateralToken { - symbol - } + user { id } + loanToken { symbol } + collateralToken { symbol } } } `; @@ -220,78 +257,64 @@ async function fetchSovrynBorrowerPositions( }; try { - // Use a direct POST with a { query } JSON body, matching typical GraphQL usage. - const httpResponse = await fetch(SOVRYN_SUBGRAPH_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - }, - body: JSON.stringify({ query }), - }); - - const text = await httpResponse.text(); - - if (!httpResponse.ok) { - console.warn( - "[risk][dataSources] Sovryn loans HTTP error:", - httpResponse.status, - httpResponse.statusText, - "- body:", - text.slice(0, 300) - ); - return []; - } + const allLoans: SovrynLoan[] = []; + let lastId: string | null = ""; - let json: { + type SovrynLoansResponse = { data?: { loans?: SovrynLoan[] }; errors?: Array<{ message: string }>; }; - try { - json = JSON.parse(text); - } catch (parseError) { - console.warn( - "[risk][dataSources] Sovryn loans response was not valid JSON. First 300 chars:", - text.slice(0, 300) - ); - return []; - } + for (let page = 0; page < 50; page++) { + const resp: SovrynLoansResponse = await fetchGraphQL({ + url: SOVRYN_SUBGRAPH_URL, + query, + variables: { lastId }, + timeoutMs, + }); + + if (resp.errors && resp.errors.length > 0) { + logWarning( + isExternal, + `Sovryn subgraph GraphQL errors: ${resp.errors + .map((e: { message: string }) => e.message) + .join(", ")}` + ); + return []; + } - if (json.errors && json.errors.length > 0) { - console.warn( - "[risk][dataSources] Sovryn loans query returned GraphQL errors:", - json.errors.map((e) => e.message).join(", ") - ); - return []; - } + const loans: SovrynLoan[] = resp.data?.loans ?? []; + if (loans.length === 0) break; - const loans = json.data?.loans ?? []; + allLoans.push(...loans); + lastId = loans[loans.length - 1]?.id ?? lastId; + if (loans.length < 1000) break; + } - const rawPositions: RawPosition[] = loans + const rawPositions: RawPosition[] = allLoans .filter( (loan) => - loan.borrowedAmount && - loan.positionSize && - loan.user && - loan.loanToken && - loan.collateralToken + loan.user?.id && + loan.loanToken?.symbol && + loan.collateralToken?.symbol && + loan.borrowedAmount != null && + loan.positionSize != null ) .map((loan) => ({ id: loan.id, borrower: loan.user!.id, collateralAsset: loan.collateralToken!.symbol, - collateralAmount: loan.positionSize as string, + collateralAmount: String(loan.positionSize ?? ""), borrowAsset: loan.loanToken!.symbol, - borrowAmount: loan.borrowedAmount as string, + borrowAmount: String(loan.borrowedAmount ?? ""), liquidationThreshold: null, })); return mapRawPositionsToBorrowPositions(rawPositions, "sovryn-v1"); } catch (error: any) { - console.warn( - "[risk][dataSources] Failed to fetch Sovryn borrower positions:", - error?.message || String(error) + logWarning( + isExternal, + `Failed to fetch Sovryn borrower positions: ${error?.message || String(error)}` ); return []; } @@ -300,8 +323,6 @@ async function fetchSovrynBorrowerPositions( async function fetchTropykusBorrowerPositions( options: PositionFetchOptions ): Promise { - // Tropykus integration is currently disabled. We return an empty - // array without logging to avoid noisy output in the CLI. void options; return []; } diff --git a/src/services/risk/engine.ts b/src/services/risk/engine.ts index 42eb8a9..5fb6663 100644 --- a/src/services/risk/engine.ts +++ b/src/services/risk/engine.ts @@ -21,6 +21,7 @@ import { TROPYKUS_RISK_CONFIG, } from "./protocols/tropykus.js"; import { buildProtocolSimulationResult, buildRiskSimulationResult } from "./reporting.js"; +import { estimateInsolvencyThresholdForProtocol } from "./reporting.js"; const DEFAULT_PROTOCOL_CONFIGS: Record = { "sovryn-v1": SOVRYN_RISK_CONFIG, @@ -42,6 +43,10 @@ function applyShockToPrices( for (const [symbol, price] of Object.entries(basePrices)) { const sym = symbol as AssetSymbol; + if (sym === "usd") { + shocked[symbol] = price; + continue; + } if (impactedAssets.includes(sym)) { shocked[symbol] = price * factor; } else { @@ -67,7 +72,7 @@ function computeHealthFactor( debtUsd: number, liquidationThreshold: number ): number { - if (debtUsd <= 0) return Number.POSITIVE_INFINITY; + if (debtUsd <= 0) return 1e30; const adjustedCollateral = collateralUsd * liquidationThreshold; return adjustedCollateral / debtUsd; } @@ -104,10 +109,19 @@ function simulateLiquidationForPosition( }; } - const ltv = debtValue / collateralValue; + const isLiquidatable = (collateralUsd: number, debtUsd: number): boolean => { + if (debtUsd <= 0) return false; + if (collateralUsd <= 0) return true; + const ltv = debtUsd / collateralUsd; + const hf = computeHealthFactor( + collateralUsd, + debtUsd, + params.liquidationThreshold + ); + return ltv > params.maxLtv || hf < 1; + }; - if (ltv <= params.maxLtv) { - // Not liquidatable under current rules. + if (!isLiquidatable(collateralValue, debtValue)) { return { positionId: position.id, protocol: position.protocol, @@ -123,14 +137,12 @@ function simulateLiquidationForPosition( let remainingCollateral = collateralValue; let remainingDebt = debtValue; - // Simple loop: attempt up to a few close-factor based liquidations until - // position becomes healthy or collateral is exhausted. - const maxSteps = 5; + const maxSteps = 60; + const minDebtEpsilon = 1e-9; + for (let i = 0; i < maxSteps; i++) { - const currentLtv = remainingDebt / remainingCollateral; - if (currentLtv <= params.maxLtv || remainingDebt <= 0 || remainingCollateral <= 0) { - break; - } + if (remainingDebt <= minDebtEpsilon || remainingCollateral <= 0) break; + if (!isLiquidatable(remainingCollateral, remainingDebt)) break; const repayDebt = remainingDebt * params.closeFactor; const collateralToSeize = repayDebt * (1 + params.liquidationBonus); @@ -140,7 +152,6 @@ function simulateLiquidationForPosition( let liquidationBonusUsd = repayDebt * params.liquidationBonus; if (actualCollateralSeized > remainingCollateral) { - // Not enough collateral to cover repayment + bonus; adjust down. const ratio = remainingCollateral / actualCollateralSeized; actualCollateralSeized = remainingCollateral; actualRepay = repayDebt * ratio; @@ -164,8 +175,7 @@ function simulateLiquidationForPosition( let totalBadDebtUsd = 0; let collateralDeficitUsd = 0; - if (remainingDebt > 0 && remainingCollateral <= 0) { - // Collateral completely exhausted, remaining debt is bad debt. + if (remainingDebt > minDebtEpsilon && (remainingCollateral <= 0 || isLiquidatable(remainingCollateral, remainingDebt))) { totalBadDebtUsd = remainingDebt; collateralDeficitUsd = remainingDebt; } @@ -181,11 +191,15 @@ function simulateLiquidationForPosition( } async function fetchPositionsForProtocols( - protocols: ProtocolId[] + protocols: ProtocolId[], + options: { timeoutMs: number; isExternal: boolean } ): Promise> { const entries = await Promise.all( protocols.map(async (id) => { - const positions = await fetchBorrowerPositions(id, {}); + const positions = await fetchBorrowerPositions(id, { + timeoutMs: options.timeoutMs, + isExternal: options.isExternal, + }); return [id, positions] as const; }) ); @@ -202,57 +216,45 @@ async function fetchPositionsForProtocols( return result; } -export async function runRiskSimulation( - config: RiskSimulationConfig -): Promise { - const protocolIds = config.protocols; - - const [basePrices, positionsByProtocol] = await Promise.all([ - fetchAssetPrices({}), - fetchPositionsForProtocols(protocolIds), - ]); - - const shockedPrices = applyShockToPrices( - basePrices, - config.shockPercentage, - config.shockedAssets - ); - - const prices = { - before: basePrices, - after: shockedPrices, - }; - +function simulateProtocols(params: { + protocolIds: ProtocolId[]; + positionsByProtocol: Record; + pricesBefore: AssetPriceMap; + pricesAfter: AssetPriceMap; + config: RiskSimulationConfig; +}): ProtocolSimulationResult[] { const protocolResults: ProtocolSimulationResult[] = []; - for (const protocolId of protocolIds) { - const positions = positionsByProtocol[protocolId] ?? []; + for (const protocolId of params.protocolIds) { + const positions = params.positionsByProtocol[protocolId] ?? []; const baseConfig = DEFAULT_PROTOCOL_CONFIGS[protocolId].liquidation; - const override = config.protocolConfigs?.[protocolId]; + const override = params.config.protocolConfigs?.[protocolId]; const liquidationParams = mergeLiquidationParams(baseConfig, override); const healthSnapshots: PositionHealthSnapshot[] = []; const liquidationSummaries: PositionLiquidationSummary[] = []; for (const position of positions) { - const collateralBefore = valueExposure(position.collateral, basePrices); - const debtBefore = valueExposure(position.debt, basePrices); + const collateralBefore = valueExposure(position.collateral, params.pricesBefore); + const debtBefore = valueExposure(position.debt, params.pricesBefore); const hfBefore = computeHealthFactor( collateralBefore, debtBefore, liquidationParams.liquidationThreshold ); - const collateralAfter = valueExposure(position.collateral, shockedPrices); - const debtAfter = valueExposure(position.debt, shockedPrices); + const collateralAfter = valueExposure(position.collateral, params.pricesAfter); + const debtAfter = valueExposure(position.debt, params.pricesAfter); const hfAfter = computeHealthFactor( collateralAfter, debtAfter, liquidationParams.liquidationThreshold ); - const liquidatable = hfAfter < 1; + const ltvAfter = + debtAfter <= 0 ? 0 : collateralAfter > 0 ? debtAfter / collateralAfter : Number.POSITIVE_INFINITY; + const liquidatable = hfAfter < 1 || ltvAfter > liquidationParams.maxLtv; healthSnapshots.push({ positionId: position.id, @@ -270,23 +272,81 @@ export async function runRiskSimulation( if (liquidatable) { const summary = simulateLiquidationForPosition( position, - shockedPrices, + params.pricesAfter, liquidationParams ); - if (summary) { - liquidationSummaries.push(summary); - } + if (summary) liquidationSummaries.push(summary); } } - const protocolResult = buildProtocolSimulationResult( - protocolId, - healthSnapshots, - liquidationSummaries + protocolResults.push( + buildProtocolSimulationResult(protocolId, healthSnapshots, liquidationSummaries) ); - protocolResults.push(protocolResult); } - return buildRiskSimulationResult(config, prices, protocolResults); + return protocolResults; +} + +export async function runRiskSimulation( + config: RiskSimulationConfig +): Promise { + const protocolIds = config.protocols; + const timeoutMs = config.timeoutMs ?? 30000; + const isExternal = config.isExternal ?? false; + + const [basePrices, positionsByProtocol] = await Promise.all([ + fetchAssetPrices({ isExternal, timeoutMs }), + fetchPositionsForProtocols(protocolIds, { timeoutMs, isExternal }), + ]); + + const shockedPrices = applyShockToPrices( + basePrices, + config.shockPercentage, + config.shockedAssets + ); + + const prices = { + before: basePrices, + after: shockedPrices, + }; + + const protocolResults = simulateProtocols({ + protocolIds, + positionsByProtocol, + pricesBefore: basePrices, + pricesAfter: shockedPrices, + config, + }); + + const insolvencyThresholds: RiskSimulationResult["insolvencyThresholds"] = {}; + + for (const protocolId of protocolIds) { + const positions = positionsByProtocol[protocolId] ?? []; + const collateralUsd = positions.reduce( + (sum, p) => sum + valueExposure(p.collateral, basePrices), + 0 + ); + + const estimate = estimateInsolvencyThresholdForProtocol({ + config, + protocolId, + protocolCollateralUsd: collateralUsd, + computeBadDebtUsdAtShock: (shockPercentage: number) => { + const pricesAfter = applyShockToPrices(basePrices, shockPercentage, config.shockedAssets); + const results = simulateProtocols({ + protocolIds: [protocolId], + positionsByProtocol, + pricesBefore: basePrices, + pricesAfter, + config: { ...config, shockPercentage, protocols: [protocolId] }, + }); + return results[0]?.totalBadDebtUsd ?? 0; + }, + }); + + if (estimate) insolvencyThresholds[protocolId] = estimate; + } + + return buildRiskSimulationResult(config, prices, protocolResults, insolvencyThresholds); } diff --git a/src/services/risk/protocols/sovryn.ts b/src/services/risk/protocols/sovryn.ts index a992189..cd19933 100644 --- a/src/services/risk/protocols/sovryn.ts +++ b/src/services/risk/protocols/sovryn.ts @@ -35,7 +35,6 @@ export interface SovrynPosition extends BorrowPosition { * engine API stable. */ export async function fetchSovrynPositions(): Promise { - // TODO: integrate with Sovryn indexer / contracts. return []; } diff --git a/src/services/risk/protocols/tropykus.ts b/src/services/risk/protocols/tropykus.ts index 5e9b132..399ce8d 100644 --- a/src/services/risk/protocols/tropykus.ts +++ b/src/services/risk/protocols/tropykus.ts @@ -34,7 +34,6 @@ export interface TropykusPosition extends BorrowPosition { * This function can be expanded without changing the engine interface. */ export async function fetchTropykusPositions(): Promise { - // TODO: integrate with Tropykus indexer / contracts. return []; } diff --git a/src/services/risk/reporting.ts b/src/services/risk/reporting.ts index 375ca37..0548be5 100644 --- a/src/services/risk/reporting.ts +++ b/src/services/risk/reporting.ts @@ -40,22 +40,45 @@ export function buildProtocolSimulationResult( * * This is a heuristic and should be refined once more precise models are available. */ -export function estimateInsolvencyThresholdForProtocol( - config: RiskSimulationConfig, - protocolResult: ProtocolSimulationResult -): InsolvencyThresholdEstimate | undefined { - const currentShock = config.shockPercentage; - const currentBadDebt = protocolResult.totalBadDebtUsd; +export function estimateInsolvencyThresholdForProtocol(params: { + config: RiskSimulationConfig; + protocolId: ProtocolId; + protocolCollateralUsd: number; + computeBadDebtUsdAtShock: (shockPercentage: number) => number; +}): InsolvencyThresholdEstimate | undefined { + const ratio = params.config.insolvencyBadDebtRatio ?? 0.01; + if (!Number.isFinite(ratio) || ratio <= 0) return undefined; + + const targetBadDebt = params.protocolCollateralUsd * ratio; + if (!Number.isFinite(targetBadDebt) || targetBadDebt <= 0) return undefined; + + const lowBound = 0; + const highBound = 99; - if (currentShock <= 0 || currentBadDebt <= 0) { + let low = lowBound; + let high = highBound; + + const badDebtAtHigh = params.computeBadDebtUsdAtShock(high); + if (!(badDebtAtHigh >= targetBadDebt)) { return undefined; } - // Linear heuristic: assume bad debt grows roughly proportional to shock. - // We treat the current shock as the insolvency threshold if any bad debt appears. + for (let i = 0; i < 20; i++) { + const mid = (low + high) / 2; + const badDebt = params.computeBadDebtUsdAtShock(mid); + if (badDebt >= targetBadDebt) { + high = mid; + } else { + low = mid; + } + } + + const shock = high; + const badDebt = params.computeBadDebtUsdAtShock(shock); + return { - shockPercentage: currentShock, - badDebtUsd: currentBadDebt, + shockPercentage: shock, + badDebtUsd: badDebt, }; } @@ -66,7 +89,8 @@ export function estimateInsolvencyThresholdForProtocol( export function buildRiskSimulationResult( config: RiskSimulationConfig, prices: RiskSimulationResult["prices"], - protocolResults: ProtocolSimulationResult[] + protocolResults: ProtocolSimulationResult[], + insolvencyThresholds: RiskSimulationResult["insolvencyThresholds"] = {} ): RiskSimulationResult { const totals = protocolResults.reduce( (acc, p) => { @@ -77,15 +101,6 @@ export function buildRiskSimulationResult( { totalBadDebtUsd: 0, totalCollateralDeficitUsd: 0 } ); - const insolvencyThresholds: RiskSimulationResult["insolvencyThresholds"] = {}; - - for (const result of protocolResults) { - const estimate = estimateInsolvencyThresholdForProtocol(config, result); - if (estimate) { - insolvencyThresholds[result.protocol] = estimate; - } - } - return { config, prices, diff --git a/src/services/risk/types.ts b/src/services/risk/types.ts index 7927750..78cd5d4 100644 --- a/src/services/risk/types.ts +++ b/src/services/risk/types.ts @@ -1,8 +1,5 @@ export type ProtocolId = "sovryn-v1" | "tropykus-v2"; -// Internal asset symbols used by the risk engine. -// These should correspond either to CoinGecko IDs (via COINGECKO_IDS) -// or to synthetic assets we treat specially (e.g. usd = 1). export type AssetSymbol = "rbtc" | "rif" | "dllr" | "sov" | "usd"; export interface AssetPriceMap { @@ -84,6 +81,22 @@ export interface RiskSimulationConfig { * Optional protocol-specific overrides for liquidation params. */ protocolConfigs?: Partial>>; + + /** + * When true, suppresses all logs (used by MCP/external consumers). + */ + isExternal?: boolean; + + /** + * Network timeout for external API calls (ms). + */ + timeoutMs?: number; + + /** + * Bad-debt ratio of protocol collateral value used to define insolvency. + * Example: 0.01 means "insolvent when bad debt >= 1% of total collateral value". + */ + insolvencyBadDebtRatio?: number; } export interface PositionHealthSnapshot { diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..847285b --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,5 @@ +export function formatNumber(value: number, decimals = 2): string { + if (!Number.isFinite(value)) return "āˆž"; + return value.toFixed(decimals); +} +