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..13fa77c --- /dev/null +++ b/src/commands/risk/report.ts @@ -0,0 +1,96 @@ +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"; +import { formatNumber } from "../../utils/format.js"; + +export interface RiskReportCliOptions { + format?: "table" | "json"; + shock?: number; + isExternal?: boolean; +} + +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 (!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, + 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; + } + + 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..63542af --- /dev/null +++ b/src/commands/risk/sandbox.ts @@ -0,0 +1,148 @@ +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"; +import { formatNumber } from "../../utils/format.js"; + +export interface RiskSandboxCliOptions { + ltv?: number; + threshold?: number; + isExternal?: boolean; +} + +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 && !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) + ) { + logError( + isExternal, + "Liquidation threshold must be between 0 and 100 (exclusive)." + ); + return; + } + + const spinner = createSpinner(isExternal); + + try { + const baseConfig: RiskSimulationConfig = { + shockPercentage: 30, + 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..4828cf9 --- /dev/null +++ b/src/commands/risk/simulate.ts @@ -0,0 +1,126 @@ +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"; +import { formatNumber } from "../../utils/format.js"; + +export interface RiskSimulateCliOptions { + shock: number; + asset?: string; + isExternal?: boolean; +} + +const ALLOWED_ASSETS: AssetSymbol[] = ["rbtc", "rif", "dllr", "sov", "usd"]; + +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 (!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: asset ? [asset as AssetSymbol] : undefined, + 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..927d8f4 --- /dev/null +++ b/src/services/risk/dataSources.ts @@ -0,0 +1,350 @@ +import { AssetPriceMap, AssetSymbol, BorrowPosition, ProtocolId } from "./types.js"; +import { logWarning } from "../../utils/logger.js"; + +const COINGECKO_IDS: Partial> = { + rbtc: "rootstock", + rif: "rif-token", + dllr: "dllr", + sov: "sovryn", +}; + +const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"; + +export const SOVRYN_SUBGRAPH_URL = + process.env.SOVRYN_SUBGRAPH_URL || + "https://subgraph.sovryn.app/subgraphs/name/DistributedCollective/sovryn-subgraph"; + +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 timeoutMs = options.timeoutMs ?? 30000; + const isExternal = options.isExternal ?? false; + + const ids = assets + .map((symbol) => COINGECKO_IDS[symbol]) + .filter((v): v is string => typeof v === "string") + .join(","); + + const priceMap: AssetPriceMap = { usd: 1 }; + if (!ids) return priceMap; + + const url = `${COINGECKO_BASE_URL}/simple/price?ids=${encodeURIComponent( + ids + )}&vs_currencies=${encodeURIComponent(vsCurrency)}`; + + 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}` + ); + } + + const data = (await response.json()) as Record>; + + 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]; + } + } + + return priceMap; +} + +export interface PositionFetchOptions { + /** + * Optional hint to use testnet endpoints or mocks. + */ + testnet?: boolean; + timeoutMs?: number; + isExternal?: boolean; +} + +interface RawPosition { + id: string; + borrower: string; + collateralAsset: string; + collateralAmount: string; + borrowAsset: string; + borrowAmount: string; + liquidationThreshold?: string | number | null; +} + +async function fetchGraphQL(params: { + url: string; + query: string; + variables?: Record; + timeoutMs: number; +}): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs); + + try { + const response = await fetch(params.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ query: params.query, variables: params.variables }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return (await response.json()) as T; + } finally { + clearTimeout(timeoutId); + } +} + +const SYMBOL_TO_ASSET: Record = { + rbtc: "rbtc", + wrbtc: "rbtc", + sov: "sov", + xusd: "usd", + doc: "usd", + dllr: "dllr", + rif: "rif", +}; + +function resolveAssetSymbol(symbol: string): AssetSymbol { + const lower = symbol.toLowerCase(); + if (SYMBOL_TO_ASSET[lower]) { + return SYMBOL_TO_ASSET[lower]; + } + 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 +): BorrowPosition[] { + return rawPositions.map((p) => { + const collateralAsset = resolveAssetSymbol(p.collateralAsset); + const debtAsset = resolveAssetSymbol(p.borrowAsset); + + 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, + protocol, + account: p.borrower, + collateral: [ + { + asset: collateralAsset, + amount: collateralAmount, + }, + ], + debt: [ + { + asset: debtAsset, + amount: debtAmount, + }, + ], + }; + }); +} + +async function fetchSovrynBorrowerPositions( + options: PositionFetchOptions +): Promise { + const timeoutMs = options.timeoutMs ?? 30000; + const isExternal = options.isExternal ?? false; + + const query = ` + query SovrynLoans($lastId: ID) { + loans( + first: 1000, + orderBy: id, + orderDirection: asc, + where: { id_gt: $lastId } + ) { + 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 { + const allLoans: SovrynLoan[] = []; + let lastId: string | null = ""; + + type SovrynLoansResponse = { + data?: { loans?: SovrynLoan[] }; + errors?: Array<{ message: string }>; + }; + + 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 []; + } + + const loans: SovrynLoan[] = resp.data?.loans ?? []; + if (loans.length === 0) break; + + allLoans.push(...loans); + lastId = loans[loans.length - 1]?.id ?? lastId; + if (loans.length < 1000) break; + } + + const rawPositions: RawPosition[] = allLoans + .filter( + (loan) => + 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: String(loan.positionSize ?? ""), + borrowAsset: loan.loanToken!.symbol, + borrowAmount: String(loan.borrowedAmount ?? ""), + liquidationThreshold: null, + })); + + return mapRawPositionsToBorrowPositions(rawPositions, "sovryn-v1"); + } catch (error: any) { + logWarning( + isExternal, + `Failed to fetch Sovryn borrower positions: ${error?.message || String(error)}` + ); + return []; + } +} + +async function fetchTropykusBorrowerPositions( + options: PositionFetchOptions +): Promise { + 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..5fb6663 --- /dev/null +++ b/src/services/risk/engine.ts @@ -0,0 +1,352 @@ +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"; +import { estimateInsolvencyThresholdForProtocol } 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 (sym === "usd") { + shocked[symbol] = price; + continue; + } + 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 1e30; + 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 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 (!isLiquidatable(collateralValue, debtValue)) { + 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; + + const maxSteps = 60; + const minDebtEpsilon = 1e-9; + + for (let i = 0; i < maxSteps; i++) { + if (remainingDebt <= minDebtEpsilon || remainingCollateral <= 0) break; + if (!isLiquidatable(remainingCollateral, remainingDebt)) 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) { + 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 > minDebtEpsilon && (remainingCollateral <= 0 || isLiquidatable(remainingCollateral, remainingDebt))) { + totalBadDebtUsd = remainingDebt; + collateralDeficitUsd = remainingDebt; + } + + return { + positionId: position.id, + protocol: position.protocol, + account: position.account, + totalBadDebtUsd, + collateralDeficitUsd, + steps, + }; +} + +async function fetchPositionsForProtocols( + protocols: ProtocolId[], + options: { timeoutMs: number; isExternal: boolean } +): Promise> { + const entries = await Promise.all( + protocols.map(async (id) => { + const positions = await fetchBorrowerPositions(id, { + timeoutMs: options.timeoutMs, + isExternal: options.isExternal, + }); + return [id, positions] as const; + }) + ); + + const result: Record = { + "sovryn-v1": [], + "tropykus-v2": [], + }; + + for (const [id, positions] of entries) { + result[id] = positions; + } + + return result; +} + +function simulateProtocols(params: { + protocolIds: ProtocolId[]; + positionsByProtocol: Record; + pricesBefore: AssetPriceMap; + pricesAfter: AssetPriceMap; + config: RiskSimulationConfig; +}): ProtocolSimulationResult[] { + const protocolResults: ProtocolSimulationResult[] = []; + + for (const protocolId of params.protocolIds) { + const positions = params.positionsByProtocol[protocolId] ?? []; + + const baseConfig = DEFAULT_PROTOCOL_CONFIGS[protocolId].liquidation; + 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, params.pricesBefore); + const debtBefore = valueExposure(position.debt, params.pricesBefore); + const hfBefore = computeHealthFactor( + collateralBefore, + debtBefore, + liquidationParams.liquidationThreshold + ); + + const collateralAfter = valueExposure(position.collateral, params.pricesAfter); + const debtAfter = valueExposure(position.debt, params.pricesAfter); + const hfAfter = computeHealthFactor( + collateralAfter, + debtAfter, + liquidationParams.liquidationThreshold + ); + + const ltvAfter = + debtAfter <= 0 ? 0 : collateralAfter > 0 ? debtAfter / collateralAfter : Number.POSITIVE_INFINITY; + const liquidatable = hfAfter < 1 || ltvAfter > liquidationParams.maxLtv; + + 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, + params.pricesAfter, + liquidationParams + ); + if (summary) liquidationSummaries.push(summary); + } + } + + protocolResults.push( + buildProtocolSimulationResult(protocolId, healthSnapshots, liquidationSummaries) + ); + } + + 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 new file mode 100644 index 0000000..cd19933 --- /dev/null +++ b/src/services/risk/protocols/sovryn.ts @@ -0,0 +1,42 @@ +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 { + 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..399ce8d --- /dev/null +++ b/src/services/risk/protocols/tropykus.ts @@ -0,0 +1,41 @@ +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 { + 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..0548be5 --- /dev/null +++ b/src/services/risk/reporting.ts @@ -0,0 +1,112 @@ +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(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; + + let low = lowBound; + let high = highBound; + + const badDebtAtHigh = params.computeBadDebtUsdAtShock(high); + if (!(badDebtAtHigh >= targetBadDebt)) { + return undefined; + } + + 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: shock, + badDebtUsd: badDebt, + }; +} + +/** + * 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[], + insolvencyThresholds: RiskSimulationResult["insolvencyThresholds"] = {} +): RiskSimulationResult { + const totals = protocolResults.reduce( + (acc, p) => { + acc.totalBadDebtUsd += p.totalBadDebtUsd; + acc.totalCollateralDeficitUsd += p.totalCollateralDeficitUsd; + return acc; + }, + { totalBadDebtUsd: 0, totalCollateralDeficitUsd: 0 } + ); + + 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..78cd5d4 --- /dev/null +++ b/src/services/risk/types.ts @@ -0,0 +1,232 @@ +export type ProtocolId = "sovryn-v1" | "tropykus-v2"; + +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>>; + + /** + * 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 { + 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>; +} + 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); +} +