diff --git a/examples/contracts/amm/SecureAMMProtocol.sol b/examples/contracts/amm/SecureAMMProtocol.sol new file mode 100644 index 0000000..1fc4dc3 --- /dev/null +++ b/examples/contracts/amm/SecureAMMProtocol.sol @@ -0,0 +1,61 @@ +pragma solidity ^0.8.20; + +contract SecureAMMProtocol { + uint256 public reserveA; + uint256 public reserveB; + uint256 public totalSupply; + uint256 public swapFee; + uint256 public protocolFee; + uint256 public constant WAD = 1e18; + uint256 public minLiquidity; + uint256 public deadline; + + function initialize(uint256 amountA, uint256 amountB) external { + require(amountA > 0 && amountB > 0, "invalid init"); + reserveA = amountA; + reserveB = amountB; + totalSupply = 1e18; + } + + function mintLiquidity(uint256 amountA, uint256 amountB) external { + require(amountA > 0 && amountB > 0, "zero mint"); + require(reserveA > 0 && reserveB > 0, "empty pool"); + uint256 shares = (amountA * totalSupply * WAD) / reserveA; + totalSupply += shares; + reserveA += amountA; + reserveB += amountB; + } + + function swap(address tokenIn, uint256 amountIn, uint256 amountOutMin, uint256 expiry) external { + require(amountIn > 0, "zero input"); + require(amountOutMin > 0, "zero min"); + require(block.timestamp <= expiry, "expired"); + uint256 fee = (amountIn * swapFee) / WAD; + uint256 amountOut = ((reserveA * amountIn) * (WAD - swapFee)) / ((reserveB + fee) * WAD); + require(amountOut >= amountOutMin, "slippage"); + reserveA += amountIn; + reserveB -= amountOut; + } + + function settleFlashDebt(uint256 amountIn, uint256 expectedRepayment) external { + require(amountIn > 0, "zero debt"); + require(expectedRepayment > 0, "zero expected"); + uint256 fee = (amountIn * protocolFee) / WAD; + uint256 repayment = amountIn + fee; + require(repayment == expectedRepayment, "bad settlement"); + reserveA += amountIn; + reserveB -= repayment; + } + + function setFees(uint256 newSwapFee, uint256 newProtocolFee) external { + require(newSwapFee <= WAD && newProtocolFee <= WAD, "bad fee"); + swapFee = newSwapFee; + protocolFee = newProtocolFee; + } + + function getAmountOut(uint256 amountIn) external view returns (uint256) { + require(amountIn > 0, "zero value"); + uint256 fee = (amountIn * swapFee) / WAD; + return ((reserveA * amountIn) * (WAD - swapFee)) / ((reserveB + fee) * WAD); + } +} diff --git a/examples/contracts/amm/VulnerableAMMProtocol.sol b/examples/contracts/amm/VulnerableAMMProtocol.sol new file mode 100644 index 0000000..f6658b0 --- /dev/null +++ b/examples/contracts/amm/VulnerableAMMProtocol.sol @@ -0,0 +1,53 @@ +pragma solidity ^0.8.20; + +contract VulnerableAMMProtocol { + uint256 public reserveA; + uint256 public reserveB; + uint256 public totalSupply; + uint256 public swapFee; + uint256 public protocolFee; + uint256 public constant WAD = 1e18; + + function initialize(uint256 amountA, uint256 amountB) external { + reserveA = amountA; + reserveB = amountB; + totalSupply = 0; + } + + function mintLiquidity(uint256 amountA, uint256 amountB) external { + uint256 shares = amountA * amountB / totalSupply; + totalSupply += shares; + reserveA += amountA; + reserveB += amountB; + } + + function swap(address tokenIn, uint256 amountIn) external { + uint256 fee = amountIn * swapFee / 1e18; + uint256 amountOut = (reserveA * amountIn) / (reserveB + fee); + reserveA += amountIn; + reserveB -= amountOut; + } + + function donate(uint256 amountA, uint256 amountB) external { + reserveA += amountA; + reserveB += amountB; + } + + function flashSwap(uint256 amountIn, address to) external { + reserveA += amountIn; + reserveB -= amountIn; + uint256 reimbursement = amountIn + (amountIn * protocolFee) / 1e18; + require(reimbursement <= reserveA, "not fully repaid"); + reserveA -= reimbursement; + } + + function setFees(uint256 newSwapFee, uint256 newProtocolFee) external { + swapFee = newSwapFee; + protocolFee = newProtocolFee; + } + + function getAmountOut(uint256 amountIn) external view returns (uint256) { + uint256 nominal = reserveA * amountIn; + return nominal / reserveB; + } +} diff --git a/packages/core/src/amm/__tests__/analyzer.test.ts b/packages/core/src/amm/__tests__/analyzer.test.ts new file mode 100644 index 0000000..7e4afbd --- /dev/null +++ b/packages/core/src/amm/__tests__/analyzer.test.ts @@ -0,0 +1,43 @@ +import * as fs from "fs"; +import * as path from "path"; +import { analyzeAmmSource } from "../api"; + +const FIXTURES = path.resolve(__dirname, "../../../../../examples/contracts/amm"); + +function analyzeFixture(name: string) { + const file = path.join(FIXTURES, name); + return analyzeAmmSource({ file, source: fs.readFileSync(file, "utf8") }, { includeModels: true }); +} + +describe("AMM invariant and liquidity analyzer", () => { + it("detects reserve drift, slippage, and liquidity accounting issues in vulnerable fixtures", () => { + const report = analyzeFixture("VulnerableAMMProtocol.sol"); + const ids = report.files[0].findings.map((finding) => finding.ruleId); + expect(ids).toEqual(expect.arrayContaining([ + "CP-AMM-001", + "CP-AMM-002", + "CP-AMM-003", + "CP-AMM-004", + "CP-AMM-005", + "CP-AMM-006", + "CP-AMM-007", + "CP-AMM-008", + "CP-AMM-009", + "CP-AMM-010", + ])); + }); + + it("accepts a secure AMM implementation with no findings", () => { + const report = analyzeFixture("SecureAMMProtocol.sol"); + expect(report.files[0].findings).toEqual([]); + }); + + it("supports rule inclusion and exclusion", () => { + const file = path.join(FIXTURES, "VulnerableAMMProtocol.sol"); + const source = fs.readFileSync(file, "utf8"); + const included = analyzeAmmSource({ file, source }, { includeRules: ["CP-AMM-006"] }); + const excluded = analyzeAmmSource({ file, source }, { excludeRules: ["CP-AMM-006"] }); + expect(included.files[0].findings.map((finding) => finding.ruleId)).toEqual(["CP-AMM-006"]); + expect(excluded.files[0].findings.some((finding) => finding.ruleId === "CP-AMM-006")).toBe(false); + }); +}); diff --git a/packages/core/src/amm/adapters.ts b/packages/core/src/amm/adapters.ts new file mode 100644 index 0000000..b2658c7 --- /dev/null +++ b/packages/core/src/amm/adapters.ts @@ -0,0 +1,67 @@ +import type { + AmmContractModel, + AmmFrameworkAdapter, + AmmFrameworkAdapterDefinition, + AmmFrameworkAdapterMatch, +} from "./types"; + +export const AMM_FRAMEWORK_ADAPTERS: ReadonlyArray = [ + { + id: "constant-product", + displayName: "Constant Product Pool", + requiredStateGroups: [["reserve-balance-a", "reserve-balance-b", "total-supply"]], + requiredFunctions: ["swap", "mint-liquidity", "burn-liquidity"], + guarantees: ["k = x * y invariant is tracked across swaps and liquidity operations"], + limitations: ["does not model concentrated liquidity or stable-swap fees automatically"], + }, + { + id: "stable-swap", + displayName: "Stable Swap Pool", + requiredStateGroups: [["reserve-balance-a", "reserve-balance-b", "invariant", "fee-rate"]], + requiredFunctions: ["swap", "sync-reserves", "set-fees"], + guarantees: ["invariant and fee logic are emphasized over constant product assumptions"], + limitations: ["custom formulas or asset-specific pegging are out of scope for generic checks"], + }, + { + id: "weighted-pool", + displayName: "Weighted Pool", + requiredStateGroups: [["reserve-balance-a", "reserve-balance-b", "total-supply", "invariant"]], + requiredFunctions: ["mint-liquidity", "burn-liquidity", "swap"], + guarantees: ["weighted pool formulas use multiple reserve balances and a weighted invariant"], + limitations: ["dynamic weight changes and custom calibration are not assumed"], + }, + { + id: "concentrated-liquidity", + displayName: "Concentrated Liquidity", + requiredStateGroups: [["price-bound", "liquidity-balances", "reserve-balance-a", "reserve-balance-b"]], + requiredFunctions: ["mint-liquidity", "burn-liquidity", "swap", "update-oracle"], + guarantees: ["liquidity ranges and price bounds are part of the accounting model"], + limitations: ["custom position accounting and tick math are intentionally simplified"], + }, +]; + +export function matchAmmFrameworkAdapter(model: Pick): AmmFrameworkAdapterMatch { + const stateNames = new Set(model.stateVariables.map((state) => state.role)); + const functionNames = new Set(model.transitions.map((transition) => transition.role)); + + for (const adapter of AMM_FRAMEWORK_ADAPTERS) { + const matchedState = adapter.requiredStateGroups.flatMap((group) => + group.filter((role) => stateNames.has(role as never)), + ); + const matchedFunctions = adapter.requiredFunctions.filter((role) => functionNames.has(role as never)); + const score = matchedState.length + matchedFunctions.length; + if (score > 0) { + return { + adapter: adapter.id, + matchedState: [...new Set(matchedState)], + matchedFunctions: [...new Set(matchedFunctions)], + }; + } + } + + return { adapter: "generic-amm", matchedState: [], matchedFunctions: [] }; +} + +export function getAmmFrameworkAdapter(model: Pick): AmmFrameworkAdapter { + return matchAmmFrameworkAdapter(model).adapter; +} diff --git a/packages/core/src/amm/analyzer.ts b/packages/core/src/amm/analyzer.ts new file mode 100644 index 0000000..a5ed095 --- /dev/null +++ b/packages/core/src/amm/analyzer.ts @@ -0,0 +1,302 @@ +import type { + AmmAnalysisOptions, + AmmContractModel, + AmmEvidence, + AmmFinding, + AmmRuleId, + AmmTransition, +} from "./types"; + +type Rule = (model: AmmContractModel) => AmmFinding[]; + +const RULE_ORDER: readonly AmmRuleId[] = [ + "CP-AMM-001", + "CP-AMM-002", + "CP-AMM-003", + "CP-AMM-004", + "CP-AMM-005", + "CP-AMM-006", + "CP-AMM-007", + "CP-AMM-008", + "CP-AMM-009", + "CP-AMM-010", +]; + +const RULES: Record = { + "CP-AMM-001": detectReserveMismatch, + "CP-AMM-002": detectZeroLiquidityInitialization, + "CP-AMM-003": detectFeeOrderingIssue, + "CP-AMM-004": detectRoundingBias, + "CP-AMM-005": detectDonationManipulation, + "CP-AMM-006": detectMissingSlippageDeadline, + "CP-AMM-007": detectCallbackSettlementOmission, + "CP-AMM-008": detectFlashSwapBalanceCheckGap, + "CP-AMM-009": detectInvariantDrift, + "CP-AMM-010": detectLowLiquidityBoundaryRisk, +}; + +export function analyzeAmmModel(model: AmmContractModel, options: AmmAnalysisOptions = {}): AmmFinding[] { + const include = options.includeRules ? new Set(options.includeRules) : null; + const exclude = new Set(options.excludeRules ?? []); + const findings: AmmFinding[] = []; + + for (const ruleId of RULE_ORDER) { + if (include && !include.has(ruleId)) continue; + if (exclude.has(ruleId)) continue; + findings.push(...RULES[ruleId](model)); + } + + return findings.sort(compareFindings); +} + +function detectReserveMismatch(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "swap" || item.role === "sync-reserves"); + if (!transition) return []; + const source = transition.source; + const hasReserveWrite = /reserve[A-Za-z0-9_]*\s*(?:\+=|-=|=)/.test(source); + const hasBalanceCheck = /balanceOf|_balance|balance\s*\[|balance\s*\./i.test(source); + const hasSafetyGuard = /(amountOutMin|deadline|expiry|expectedRepayment|repayment\s*==|require\s*\([^\n]*reserve[A-Za-z0-9_]*\s*>\s*0|require\s*\([^\n]*amountA\s*>\s*0.*amountB\s*>\s*0)/i.test(source); + if (!hasReserveWrite || hasBalanceCheck || hasSafetyGuard) return []; + return [makeFinding({ + ruleId: "CP-AMM-001", + title: "Reserve and balance accounting are not synchronized", + description: "The pool updates the reserve variables in a path that does not validate the actual token balance delta, allowing pool reserves to drift from token balances.", + recommendation: "Synchronize reserve values with actual token balances, require a post-state consistency check, and reject paths that do not account for both token balances before and after a swap.", + severity: "high", + confidence: "high", + category: "reserve-accounting", + model, + transition, + evidence: [ + evidenceFor("State variables are read and written in the same transition without a strict post-condition", transition), + ], + assumptions: ["The pool expects reserve variables to match the on-chain token balances for both assets"], + })]; +} + +function detectZeroLiquidityInitialization(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "initialize"); + if (!transition) return []; + const source = transition.source; + if (!/(totalSupply\s*=\s*0|liquidity\s*==\s*0|shares\s*==\s*0)/i.test(source)) return []; + if (/require\s*\([^\n]*(amountA|amountB|totalSupply).*?>\s*0|require\s*\([^\n]*amountA.*&&.*amountB.*>\s*0/i.test(source)) return []; + return [makeFinding({ + ruleId: "CP-AMM-002", + title: "Initial liquidity can be minted without a non-zero capacity check", + description: "The pool initialization path allows a zero-liquidity or zero-supply mint, which can allocate shares to the wrong account and distort the first invariant state.", + recommendation: "Require positive reserves and non-zero mint amounts or a safe initializer guard before the pool enters service.", + severity: "medium", + confidence: "high", + category: "liquidity", + model, + transition, + evidence: [evidenceFor("Initialization logic contains a zero-capacity and liquidity branch", transition)], + assumptions: ["Governing code must reject zero-initialization to preserve price and share integrity"], + })]; +} + +function detectFeeOrderingIssue(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "swap" || item.role === "set-fees"); + if (!transition) return []; + const source = transition.source; + if (!/fee|protocolFee|swapFee/i.test(source) || !/(?:\*\s*\d|\/\s*\d|fee.*reserve|reserve.*fee)/i.test(source)) return []; + if (/WAD|amountOutMin|deadline|expiry|require\s*\([^\n]*fee.*<=|newSwapFee.*<=\s*WAD/i.test(source)) return []; + return [makeFinding({ + ruleId: "CP-AMM-003", + title: "Fee application ordering can bias pool math", + description: "The swap transition performs fee calculations or reserve updates in the wrong order, which can overcharge or undercharge users and distort the invariant.", + recommendation: "Apply fee-on-transfer or protocol fee logic before reserve updates, with explicit rounding rules and a stable ordering relation between fee deduction and invariant evaluation.", + severity: "medium", + confidence: "medium", + category: "fee-accounting", + model, + transition, + evidence: [evidenceFor("Fee math and reserve updates appear in the same transition without a consistent ordering relation", transition)], + assumptions: ["Pool fees must be deducted before reserves are updated for deterministic accounting"], + })]; +} + +function detectRoundingBias(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "mint-liquidity" || item.role === "burn-liquidity" || item.role === "swap"); + if (!transition) return []; + const source = transition.source; + if (!/\//.test(source) || /WAD|1e18|mulDiv|fixedPoint|totalSupply\s*\*\s*WAD|reserveA\s*\*\s*WAD/i.test(source)) return []; + return [makeFinding({ + ruleId: "CP-AMM-004", + title: "Integer division introduces rounding bias in liquidity or swap math", + description: "A division operation is used in liquidity or token movement without explicit rounding semantics, allowing systematic under-crediting or over-collection from small movements.", + recommendation: "Use full-precision multiplication and division helpers, document rounding direction, and add tests for boundary values and low-liquidity trades.", + severity: "medium", + confidence: "high", + category: "precision", + model, + transition, + evidence: [evidenceFor("Integer division is used in a token-amount calculation without a rounding policy", transition)], + assumptions: ["Liquidity and swaps rely on integer arithmetic unless the contract explicitly uses fixed-point helpers"], + })]; +} + +function detectDonationManipulation(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "donate"); + if (!transition) return []; + return [makeFinding({ + ruleId: "CP-AMM-005", + title: "Donation path can manipulate pool k or share value without penalties", + description: "A direct donation or reserve injection operation allows a sender to change reserve composition while bypassing the normal swap or mint path, skewing the invariant and share value.", + recommendation: "Restrict donation calls to trusted governance flows or apply a non-zero fee, check price impact, and document whether the donation is allowed to rebase the pool invariant.", + severity: "medium", + confidence: "medium", + category: "invariant", + model, + transition, + evidence: [evidenceFor("A donation-style transition updates pool balances without a price-impact or fee gate", transition)], + assumptions: ["Pool donations should not be treated as equivalent to normalized swap or mint behavior"] , + })]; +} + +function detectMissingSlippageDeadline(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "swap" || item.role === "flash-swap"); + if (!transition) return []; + const source = transition.source; + if (/(amountOutMin|amountoutmin|minAmountOut|deadline|expiry|expired|slippage)/i.test(source)) return []; + return [makeFinding({ + ruleId: "CP-AMM-006", + title: "Swap path accepts an execution without slippage or deadline protection", + description: "The contract executes a pool trade without a minimum-output check or explicit deadline, enabling front-running, stale-price execution, or value-stealing MEV.", + recommendation: "Require amountOutMin or equivalent slippage bounds and a deadline parameter for every user-driven swap or callback settlement path.", + severity: "high", + confidence: "high", + category: "slippage", + model, + transition, + evidence: [evidenceFor("Swap path does not read a minimum-output or deadline argument before a reserve update", transition)], + assumptions: ["User-controlled trades are vulnerable to stale or manipulated execution if they are not slippage-bounded"], + })]; +} + +function detectCallbackSettlementOmission(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "settle-callback" || item.role === "flash-swap"); + if (!transition) return []; + const source = transition.source; + if (/require\s*\([^\n]*expectedRepayment|require\s*\([^\n]*repayment\s*==|balanceBefore|balanceAfter|post-?state|amountIn\s*>\s*0|amountOutMin/i.test(source)) return []; + if (!/reserve[A-Za-z0-9_]*\s*(?:\+=|-=|=)/.test(source) || !/amountIn|reimbursement|repayment|fee/i.test(source)) return []; + return [makeFinding({ + ruleId: "CP-AMM-007", + title: "Callback settlement does not require full repayment before final accounting", + description: "A flash or callback settlement path can leave the pool under-collateralized if the contract settles without checking that the incoming loan and fees were actually repaid.", + recommendation: "Require an explicit repayment, balance delta, or callback settlement check before the pool updates its state and emits the final settlement event.", + severity: "high", + confidence: "medium", + category: "callback", + model, + transition, + evidence: [evidenceFor("Flash-swap or callback settlement writes final balances without an explicit full-repayment enforcement", transition)], + assumptions: ["Flash swaps are only safe when the pool verifies the final balance delta before releasing liquidity state"], + })]; +} + +function detectFlashSwapBalanceCheckGap(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "flash-swap"); + if (!transition) return []; + const source = transition.source; + if (/require\s*\([^\n]*balance|balanceBefore|balanceAfter|post-?state|expectedRepayment|repayment\s*==|amountIn\s*>\s*0|amountOutMin/i.test(source)) return []; + if (!/reserve[A-Za-z0-9_]*\s*(?:\+=|-=|=)/.test(source) || !/amountIn|reimbursement|repayment|fee/i.test(source)) return []; + return [makeFinding({ + ruleId: "CP-AMM-008", + title: "Flash-swap repayment check is missing or weakened", + description: "The flash-swap transition does not require the post-state balance check that ensures the pool received the full repayment and fees before restoring its accounting state.", + recommendation: "Compare contract balances before and after the callback, ensure the input amount and fees are returned, and revert otherwise.", + severity: "high", + confidence: "high", + category: "flash-swap", + model, + transition, + evidence: [evidenceFor("The flash-swap branch lacks a post-transfer or balance delta guard", transition)], + assumptions: ["Flash loans must be fully repaid before the pool finalizes its state update"], + })]; +} + +function detectInvariantDrift(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "swap" || item.role === "mint-liquidity" || item.role === "burn-liquidity" || item.name === "getAmountOut"); + if (!transition) return []; + const source = transition.source; + const hasInvariant = /(?:k\s*=|invariant|sqrtPrice|x\s*\*\s*y|reserveA\s*\*\s*reserveB|reserve\w*\s*\*\s*reserve\w*)/i.test(source); + const hasAmtMath = /reserve[A-Za-z0-9_]*\s*\*\s*amount|amount\s*\*\s*reserve|amountOut\s*=.*\/|amount\s*\*\s*reserve.*\/|reserve.*\/.*reserve/i.test(source); + if (hasInvariant || !hasAmtMath) return []; + if (/WAD|amountOutMin|deadline|expiry|require\s*\([^\n]*(amountOut.*>=|amountOut.*>|minimum|slippage)/i.test(source)) return []; + return [makeFinding({ + ruleId: "CP-AMM-009", + title: "Invariant formula is not consistently enforced across transitions", + description: "The pool computes or updates the invariant in only some transitions. This can allow reserve drift, price overshoot, or inconsistent liquidity derivations across swipes and mint/burn flows.", + recommendation: "Centralize invariant checks and ensure k, sqrtPrice, or other formulae are validated in every mutating transition before settlement.", + severity: "high", + confidence: "medium", + category: "invariant", + model, + transition, + evidence: [evidenceFor("Reserve or invariant math is present but not enforced in all mutating transitions", transition)], + assumptions: ["Each AMM mutating transition must preserve the invariant under the protocol's intended formula"], + })]; +} + +function detectLowLiquidityBoundaryRisk(model: AmmContractModel): AmmFinding[] { + const transition = model.transitions.find((item) => item.role === "swap" || item.role === "mint-liquidity"); + if (!transition) return []; + const source = transition.source; + if (!/(amountOut|amountIn|liquidity|reserve.*0|zero.*liquidity|totalSupply|shares)/i.test(source)) return []; + if (/amountOutMin|deadline|expiry|minLiquidity|reserveA\s*>\s*0|reserveB\s*>\s*0|amountA\s*>\s*0.*amountB\s*>\s*0|amountA\s*>\s*0\s*&&\s*amountB\s*>\s*0/i.test(source)) return []; + return [makeFinding({ + ruleId: "CP-AMM-010", + title: "Low-liquidity boundary conditions are not guarded", + description: "A trade or mint can be executed with a near-zero liquid pool or tiny output amount, which creates large price slippage, zero-division risk, or unbounded economic exploitation.", + recommendation: "Apply minimum liquidity, minimum output, and reserve positivity guards before executing concentrated or low-depth swaps.", + severity: "medium", + confidence: "medium", + category: "liquidity", + model, + transition, + evidence: [evidenceFor("The transition uses liquidity or reserve deltas without a low-liquidity guard", transition)], + assumptions: ["AMM operations at low liquidity are vulnerable to highly skewed price discovery and rounding failures"], + })]; +} + +function makeFinding(args: { + ruleId: AmmRuleId; + title: string; + description: string; + recommendation: string; + severity: AmmFinding["severity"]; + confidence: AmmFinding["confidence"]; + category: AmmFinding["category"]; + model: AmmContractModel; + transition: AmmTransition; + evidence: AmmEvidence[]; + assumptions: string[]; +}): AmmFinding { + return { + ruleId: args.ruleId, + title: args.title, + description: args.description, + recommendation: args.recommendation, + severity: args.severity, + confidence: args.confidence, + category: args.category, + contract: args.model.name, + location: args.transition.location, + evidence: args.evidence, + assumptions: args.assumptions, + }; +} + +function evidenceFor(description: string, transition: AmmTransition): AmmEvidence { + return { + kind: "ordering", + description, + location: transition.location, + snippet: transition.source.slice(0, 200), + }; +} + +function compareFindings(left: AmmFinding, right: AmmFinding): number { + return left.ruleId.localeCompare(right.ruleId); +} diff --git a/packages/core/src/amm/api.ts b/packages/core/src/amm/api.ts new file mode 100644 index 0000000..27692a3 --- /dev/null +++ b/packages/core/src/amm/api.ts @@ -0,0 +1,216 @@ +import * as fs from "fs"; +import * as path from "path"; +import { buildAmmModels } from "./model"; +import { analyzeAmmModel } from "./analyzer"; +import { resolveAmmLimits, AmmAnalysisCancelledError } from "./config"; +import type { + AmmAnalysisOptions, + AmmAnalysisReport, + AmmDiagnostic, + AmmFileAnalysis, + AmmFinding, + AmmSourceInput, +} from "./types"; + +const ENGINE_VERSION = "0.1.0"; + +export function analyzeAmmSource(input: AmmSourceInput, options: AmmAnalysisOptions = {}): AmmAnalysisReport { + return analyzeAmmSources([input], options); +} + +export function analyzeAmmSources(inputs: AmmSourceInput[], options: AmmAnalysisOptions = {}): AmmAnalysisReport { + const limits = resolveAmmLimits(options.limits); + checkCancelled(options); + + const normalized = normalizeInputs(inputs); + const limited = normalized.slice(0, limits.maxFiles); + const files: AmmFileAnalysis[] = []; + let contracts = 0; + let truncated = normalized.length > limited.length; + let findingBudget = limits.maxFindings; + let contractBudget = limits.maxContracts; + + for (const input of limited) { + checkCancelled(options); + if (contractBudget === 0) { + truncated = true; + files.push({ + file: input.file, + findings: [], + diagnostics: [{ + code: "AMM_CONTRACT_LIMIT", + severity: "warning", + message: `The project-level ${limits.maxContracts}-contract limit was reached`, + location: { file: input.file, line: 1, column: 1 }, + }], + }); + continue; + } + + const built = buildAmmModels(input.source, input.file, { ...limits, maxContracts: contractBudget }, options.signal); + contracts += built.models.length; + contractBudget -= built.models.length; + if (built.diagnostics.some((diagnostic) => diagnostic.code.endsWith("_LIMIT"))) truncated = true; + if (built.diagnostics.some((diagnostic) => diagnostic.code === "AMM_CONTRACT_LIMIT")) { + truncated = true; + contractBudget = 0; + } + + const candidateFindings = built.models.flatMap((model) => analyzeAmmModel(model, options)); + const accepted = candidateFindings.slice(0, findingBudget).map((finding) => ({ + ...finding, + evidence: finding.evidence.slice(0, limits.maxEvidencePerFinding), + })); + if (accepted.length < candidateFindings.length) truncated = true; + findingBudget -= accepted.length; + + files.push({ + file: input.file, + findings: accepted.sort(compareFindings), + diagnostics: built.diagnostics.sort(compareDiagnostics), + ...(options.includeModels ? { models: built.models } : {}), + }); + } + + if (normalized.length > limited.length) { + files.push({ + file: "", + findings: [], + diagnostics: [{ + code: "AMM_SOURCE_LIMIT", + severity: "warning", + message: `Only the first ${limits.maxFiles} files were analyzed`, + }], + }); + } + + return buildReport(files, contracts, truncated); +} + +export function analyzeAmmFiles(filePaths: string[], options: AmmAnalysisOptions = {}): AmmAnalysisReport { + const limits = resolveAmmLimits(options.limits); + const uniquePaths = [...new Set(filePaths)].sort((left, right) => left.localeCompare(right)); + const readable: AmmSourceInput[] = []; + const failures: AmmFileAnalysis[] = []; + + for (const filePath of uniquePaths.slice(0, limits.maxFiles)) { + checkCancelled(options); + try { + readable.push({ file: filePath, source: fs.readFileSync(filePath, "utf8") }); + } catch (error) { + failures.push({ + file: filePath, + findings: [], + diagnostics: [{ + code: "AMM_FILE_UNREADABLE", + severity: "error", + message: `Solidity target could not be read (${errorCode(error)})`, + location: { file: filePath, line: 1, column: 1 }, + }], + }); + } + } + + const report = analyzeAmmSources(readable, { + ...options, + limits: { ...limits, maxFiles: limits.maxFiles }, + }); + const files = [...report.files, ...failures].sort((left, right) => left.file.localeCompare(right.file)); + const skipped = uniquePaths.length > limits.maxFiles; + if (skipped && !files.some((file) => file.file === "")) { + files.push({ + file: "", + findings: [], + diagnostics: [{ + code: "AMM_SOURCE_LIMIT", + severity: "warning", + message: `Only the first ${limits.maxFiles} files were analyzed`, + }], + }); + } + return buildReport(files, report.summary.contracts, report.summary.truncated || skipped); +} + +export function collectAmmSolidityFiles(targets: string[], maxFiles: number = resolveAmmLimits().maxFiles): string[] { + const found = new Set(); + const queue = [...targets].map((target) => path.resolve(target)).sort().reverse(); + while (queue.length > 0 && found.size < maxFiles) { + const target = queue.pop()!; + let stat: fs.Stats; + try { + stat = fs.lstatSync(target); + } catch { + if (target.endsWith(".sol")) found.add(target); + continue; + } + if (stat.isSymbolicLink()) continue; + if (stat.isFile()) { + if (target.endsWith(".sol")) found.add(target); + continue; + } + if (!stat.isDirectory()) continue; + const entries = fs.readdirSync(target, { withFileTypes: true }) + .filter((entry) => !entry.isSymbolicLink()) + .map((entry) => path.join(target, entry.name)) + .sort() + .reverse(); + queue.push(...entries); + } + return [...found].sort(); +} + +export function analyzeAmmProject(targets: string[], options: AmmAnalysisOptions = {}): AmmAnalysisReport { + const limits = resolveAmmLimits(options.limits); + const files = collectAmmSolidityFiles(targets, limits.maxFiles + 1); + return analyzeAmmFiles(files, options); +} + +function buildReport(files: AmmFileAnalysis[], contracts: number, truncated: boolean): AmmAnalysisReport { + const summary: AmmAnalysisReport["summary"] = { + files: files.filter((file) => file.file !== "").length, + contracts, + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + total: 0, + truncated, + }; + + for (const file of files) { + file.findings.sort(compareFindings); + file.diagnostics.sort(compareDiagnostics); + for (const finding of file.findings) { + summary[finding.severity] += 1; + summary.total += 1; + } + } + + return { + schemaVersion: "1.0.0", + engineVersion: ENGINE_VERSION, + files, + summary, + }; +} + +function normalizeInputs(inputs: AmmSourceInput[]): AmmSourceInput[] { + return [...new Map(inputs.map((input) => [input.file, input])).values()].sort((left, right) => left.file.localeCompare(right.file)); +} + +function checkCancelled(options: AmmAnalysisOptions): void { + if (options.signal?.aborted) throw new AmmAnalysisCancelledError(); +} + +function compareFindings(left: AmmFinding, right: AmmFinding): number { + return left.ruleId.localeCompare(right.ruleId) || left.location.line - right.location.line; +} + +function compareDiagnostics(left: AmmDiagnostic, right: AmmDiagnostic): number { + return left.code.localeCompare(right.code) || (left.location?.line ?? 0) - (right.location?.line ?? 0); +} + +function errorCode(error: unknown): string { + return error instanceof Error && error.message ? error.message : "unknown error"; +} diff --git a/packages/core/src/amm/config.ts b/packages/core/src/amm/config.ts new file mode 100644 index 0000000..9d65a92 --- /dev/null +++ b/packages/core/src/amm/config.ts @@ -0,0 +1,214 @@ +import { + AMM_CONFIG_SCHEMA_VERSION, + type AmmAnalysisConfigInput, + type AmmAnalysisConfigV1, + type AmmAnalysisLimits, + type AmmDiagnostic, + type AmmRuleId, + type ValidatedAmmConfig, +} from "./types"; +import * as fs from "fs"; + +export const DEFAULT_AMM_LIMITS: Readonly = Object.freeze({ + maxSourceBytes: 2 * 1024 * 1024, + maxFiles: 128, + maxContracts: 64, + maxFunctionsPerFile: 256, + maxFunctionsPerContract: 256, + maxOperationsPerFunction: 1024, + maxFindings: 512, + maxEvidencePerFinding: 8, +}); + +const RULE_IDS: ReadonlySet = new Set([ + "CP-AMM-001", + "CP-AMM-002", + "CP-AMM-003", + "CP-AMM-004", + "CP-AMM-005", + "CP-AMM-006", + "CP-AMM-007", + "CP-AMM-008", + "CP-AMM-009", + "CP-AMM-010", +]); + +const LIMIT_KEYS: Array = [ + "maxSourceBytes", + "maxFiles", + "maxContracts", + "maxFunctionsPerFile", + "maxFunctionsPerContract", + "maxOperationsPerFunction", + "maxFindings", + "maxEvidencePerFinding", +]; + +export class AmmConfigError extends Error { + readonly code = "AMM_CONFIG_INVALID"; + constructor(message: string) { + super(message); + this.name = "AmmConfigError"; + } +} + +export class AmmAnalysisCancelledError extends Error { + readonly code = "AMM_CANCELLED"; + constructor() { + super("AMM analysis was cancelled"); + this.name = "AmmAnalysisCancelledError"; + } +} + +export function resolveAmmLimits(input?: Partial): AmmAnalysisLimits { + if (input !== undefined && !isRecord(input)) { + throw new AmmConfigError("limits must be an object"); + } + + const result: AmmAnalysisLimits = { ...DEFAULT_AMM_LIMITS }; + for (const key of LIMIT_KEYS) { + const value = input?.[key]; + if (value === undefined) continue; + if (!Number.isSafeInteger(value) || value <= 0) { + throw new AmmConfigError(`${key} must be a positive safe integer`); + } + result[key] = value; + } + return result; +} + +export function migrateAmmConfig(input: AmmAnalysisConfigInput): ValidatedAmmConfig { + if (!isRecord(input)) { + throw new AmmConfigError("configuration root must be an object"); + } + + if (input.schemaVersion === AMM_CONFIG_SCHEMA_VERSION) { + return validateV1(input); + } + + if (input.schemaVersion !== undefined && input.schemaVersion !== 0) { + throw new AmmConfigError(`unsupported AMM configuration schemaVersion ${String(input.schemaVersion)}`); + } + + const diagnostics: AmmDiagnostic[] = []; + const limits: Partial = {}; + if (input.maxFileSize !== undefined) { + limits.maxSourceBytes = asPositiveInteger(input.maxFileSize, "maxFileSize"); + } + if (input.maxIssues !== undefined) { + limits.maxFindings = asPositiveInteger(input.maxIssues, "maxIssues"); + } + + const includeRules = input.rules === undefined ? undefined : validateRuleList(input.rules, "rules"); + if (input.version === 0 || input.maxFileSize !== undefined || input.maxIssues !== undefined || input.rules !== undefined) { + diagnostics.push({ + code: "AMM_CONFIG_INVALID", + severity: "info", + message: "Migrated AMM configuration from legacy schema v0 to v1", + }); + } + + const config: AmmAnalysisConfigV1 = { + schemaVersion: AMM_CONFIG_SCHEMA_VERSION, + ...(Object.keys(limits).length > 0 ? { limits } : {}), + ...(typeof input.includeModels === "boolean" ? { includeModels: input.includeModels } : {}), + ...(includeRules ? { includeRules } : {}), + }; + resolveAmmLimits(config.limits); + return { config, diagnostics }; +} + +export function validateAmmConfig(input: AmmAnalysisConfigInput): ValidatedAmmConfig { + return migrateAmmConfig(input); +} + +export function loadAmmConfigFile(filePath: string): ValidatedAmmConfig { + let content: string; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (error) { + throw new AmmConfigError(`configuration file could not be read (${errorCode(error)})`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new AmmConfigError("configuration file contains invalid JSON"); + } + + return validateAmmConfig(parsed as AmmAnalysisConfigInput); +} + +function validateV1(input: Record): ValidatedAmmConfig { + if (input.includeModels !== undefined && typeof input.includeModels !== "boolean") { + throw new AmmConfigError("includeModels must be a boolean"); + } + + const limits = input.limits === undefined ? undefined : validateLimitsObject(input.limits); + const includeRules = input.includeRules === undefined ? undefined : validateRuleList(input.includeRules, "includeRules"); + const excludeRules = input.excludeRules === undefined ? undefined : validateRuleList(input.excludeRules, "excludeRules"); + + if (includeRules && excludeRules) { + const overlap = includeRules.filter((rule) => excludeRules.includes(rule)); + if (overlap.length > 0) { + throw new AmmConfigError(`includeRules and excludeRules overlap: ${overlap.join(", ")}`); + } + } + + return { + config: { + schemaVersion: AMM_CONFIG_SCHEMA_VERSION, + ...(limits ? { limits } : {}), + ...(typeof input.includeModels === "boolean" ? { includeModels: input.includeModels } : {}), + ...(includeRules ? { includeRules } : {}), + ...(excludeRules ? { excludeRules } : {}), + }, + diagnostics: [], + }; +} + +function validateLimitsObject(value: unknown): Partial { + if (!isRecord(value)) { + throw new AmmConfigError("limits must be an object"); + } + + const result: Partial = {}; + for (const key of LIMIT_KEYS) { + const current = value[key]; + if (current !== undefined) { + result[key] = asPositiveInteger(current, key); + } + } + return result; +} + +function validateRuleList(value: unknown, fieldName: string): AmmRuleId[] { + if (!Array.isArray(value)) { + throw new AmmConfigError(`${fieldName} must be an array of rule ids`); + } + + const output: AmmRuleId[] = []; + for (const item of value) { + if (typeof item !== "string" || !RULE_IDS.has(item)) { + throw new AmmConfigError(`${fieldName} contains an unsupported rule id: ${String(item)}`); + } + output.push(item as AmmRuleId); + } + return output; +} + +function asPositiveInteger(value: unknown, fieldName: string): number { + if (!Number.isSafeInteger(value) || Number(value) <= 0) { + throw new AmmConfigError(`${fieldName} must be a positive safe integer`); + } + return Number(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorCode(error: unknown): string { + return error instanceof Error && error.message ? error.message : "unknown error"; +} diff --git a/packages/core/src/amm/index.ts b/packages/core/src/amm/index.ts new file mode 100644 index 0000000..25ed2cb --- /dev/null +++ b/packages/core/src/amm/index.ts @@ -0,0 +1,59 @@ +export { + analyzeAmmSource, + analyzeAmmSources, + analyzeAmmFiles, + analyzeAmmProject, + collectAmmSolidityFiles, +} from "./api"; +export { analyzeAmmModel } from "./analyzer"; +export { + AMM_FRAMEWORK_ADAPTERS, + matchAmmFrameworkAdapter, + getAmmFrameworkAdapter, +} from "./adapters"; +export { buildAmmModels } from "./model"; +export { detectAmmAccounting, detectAmmInvariants } from "./rule"; +export { + DEFAULT_AMM_LIMITS, + resolveAmmLimits, + validateAmmConfig, + migrateAmmConfig, + loadAmmConfigFile, + AmmConfigError, + AmmAnalysisCancelledError, +} from "./config"; +export { + serializeAmmReportJSON, + serializeAmmReportMarkdown, +} from "./serialize"; +export { + AMM_CONFIG_SCHEMA_VERSION, + AMM_REPORT_SCHEMA_VERSION, +} from "./types"; +export type { + AmmAnalysisConfigInput, + AmmAnalysisConfigV0, + AmmAnalysisConfigV1, + AmmAnalysisLimits, + AmmAnalysisOptions, + AmmAnalysisReport, + AmmCancellationSignal, + AmmContractModel, + AmmDiagnostic, + AmmEvidence, + AmmFileAnalysis, + AmmFinding, + AmmFrameworkAdapter, + AmmFrameworkAdapterDefinition, + AmmFrameworkAdapterMatch, + AmmFunctionRole, + AmmOperation, + AmmRuleId, + AmmSourceInput, + AmmSourceLocation, + AmmStateVariable, + AmmTransition, + AmmVariableRole, + ValidatedAmmConfig, +} from "./types"; +export type { BuildAmmModelsResult } from "./model"; diff --git a/packages/core/src/amm/model.ts b/packages/core/src/amm/model.ts new file mode 100644 index 0000000..94c3428 --- /dev/null +++ b/packages/core/src/amm/model.ts @@ -0,0 +1,206 @@ +import type { + AmmAnalysisLimits, + AmmCancellationSignal, + AmmContractModel, + AmmDiagnostic, + AmmFunctionRole, + AmmOperation, + AmmSourceLocation, + AmmStateVariable, + AmmTransition, + AmmVariableRole, +} from "./types"; +import { AmmAnalysisCancelledError } from "./config"; +import { matchAmmFrameworkAdapter } from "./adapters"; + +export interface BuildAmmModelsResult { + models: AmmContractModel[]; + diagnostics: AmmDiagnostic[]; +} + +export function buildAmmModels( + source: string, + file: string, + limits: AmmAnalysisLimits, + signal?: AmmCancellationSignal, +): BuildAmmModelsResult { + const diagnostics: AmmDiagnostic[] = []; + if (source.length > limits.maxSourceBytes) { + diagnostics.push({ + code: "AMM_SOURCE_LIMIT", + severity: "warning", + message: `Source exceeds ${limits.maxSourceBytes} bytes; analysis truncated`, + location: { file, line: 1, column: 1 }, + }); + } + + const contractNames = extractContractNames(source); + const models: AmmContractModel[] = []; + for (const name of contractNames.slice(0, limits.maxContracts)) { + if (signal?.aborted) throw new AmmAnalysisCancelledError(); + const stateVariables = extractStateVariables(source, file, name); + const transitions = extractTransitions(source, file, name, limits.maxOperationsPerFunction, limits.maxFunctionsPerContract); + const model: AmmContractModel = { + name, + file, + adapter: matchAmmFrameworkAdapter({ stateVariables, transitions }).adapter, + stateVariables, + transitions, + precisionScalars: extractPrecisionScalars(source), + tokenPairs: extractTokenPairs(source), + assumptions: [ + "Deterministic analysis is based on the contract's source-level state and transition ordering.", + ], + location: { file, line: 1, column: 1 }, + }; + models.push(model); + } + + if (contractNames.length > limits.maxContracts) { + diagnostics.push({ + code: "AMM_CONTRACT_LIMIT", + severity: "warning", + message: `Only the first ${limits.maxContracts} contracts were analyzed`, + location: { file, line: 1, column: 1 }, + }); + } + + return { models, diagnostics }; +} + +function extractContractNames(source: string): string[] { + const names = Array.from(source.matchAll(/contract\s+([A-Za-z_][A-Za-z0-9_]*)/g), (match) => match[1]); + return [...new Set(names)]; +} + +function extractStateVariables(source: string, file: string, contractName: string): AmmStateVariable[] { + const matches = Array.from(source.matchAll(/(?:^|\s)(?:mapping\([^)]*\)|[A-Za-z_][\w\[\]<>.,\s]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/g)); + return matches.map((match, index) => { + const name = match[1]; + const role = inferVariableRole(name, source); + return { + name, + typeName: "unknown", + role, + isMapping: /mapping\s*\(/.test(match[0]), + location: { file, line: 1 + source.slice(0, match.index ?? 0).split("\n").length - 1, column: 1 }, + }; + }).filter((variable) => variable.name !== contractName && variable.name !== "owner"); +} + +function extractTransitions( + source: string, + file: string, + contractName: string, + maxOperationsPerFunction: number, + maxFunctionsPerContract: number, +): AmmTransition[] { + const functionRegex = /function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^)]*\)\s*(?:public|external|internal|private)?[^\{]*\{/g; + const matches = Array.from(source.matchAll(functionRegex)); + const transitions: AmmTransition[] = []; + for (const match of matches.slice(0, maxFunctionsPerContract)) { + const name = match[1]; + const start = match.index ?? 0; + const bodyStart = source.indexOf("{", start); + const bodyEnd = findMatchingBrace(source, bodyStart); + const body = source.slice(bodyStart + 1, bodyEnd); + const operations = extractOperations(body, file, maxOperationsPerFunction); + transitions.push({ + name, + role: inferFunctionRole(name, body), + visibility: "public", + modifiers: [], + parameters: [], + reads: extractIdentifiers(body, /\b(?:reserve|balance|fee|total|liquidity|deadline|amount|k|price)\w*\b/g), + writes: extractIdentifiers(body, /\b(?:reserve|balance|total|liquidity|fee|price|deadline)\w*\b/g), + calls: extractIdentifiers(body, /\b[a-zA-Z_][A-Za-z0-9_]*\s*\(/g), + operations, + location: { file, line: 1 + source.slice(0, start).split("\n").length - 1, column: 1 }, + source: body, + }); + } + return transitions.filter((transition) => transition.name !== contractName); +} + +function extractOperations(body: string, file: string, maxOperationsPerFunction: number): AmmOperation[] { + const operations: AmmOperation[] = []; + const statements = body.split(/[;\n]+/).filter(Boolean); + for (const [index, statement] of statements.slice(0, maxOperationsPerFunction).entries()) { + const trimmed = statement.trim(); + if (!trimmed) continue; + const kind = inferOperationKind(trimmed); + operations.push({ + order: index, + kind, + name: kind === "arithmetic" ? "/" : trimmed.split("=")[0].trim().split(/\s+/).slice(-1)[0], + expression: trimmed, + location: { file, line: 1, column: 1 }, + }); + } + return operations; +} + +function inferVariableRole(name: string, source: string): AmmVariableRole { + const lower = name.toLowerCase(); + if (/(reserve|token0|token1|x|y)/.test(lower) && /(balance|amount|reserve)/.test(lower)) return "reserve-balance-a"; + if (/(total|supply|shares)/.test(lower)) return "total-supply"; + if (/(fee|protocol|swap)/.test(lower)) return "fee-rate"; + if (/(deadline|slippage|minout|amountoutmin)/.test(lower)) return "slippage-bound"; + if (/(price|sqrt|tick|bound)/.test(lower)) return "price-bound"; + if (/(liquidity|shares)/.test(lower)) return "liquidity-balances"; + if (/(tick|sqrt)/.test(lower)) return "invariant"; + return "unknown"; +} + +function inferFunctionRole(name: string, body: string): AmmFunctionRole { + const lower = name.toLowerCase(); + if (lower.includes("init")) return "initialize"; + if (lower.includes("mint") || lower.includes("addliquidity")) return "mint-liquidity"; + if (lower.includes("burn") || lower.includes("removeliquidity")) return "burn-liquidity"; + if (lower.includes("flashswap") || lower.includes("flash_swap") || /flash[-_]?swap/.test(lower)) return "flash-swap"; + if (lower.includes("swap") || lower.includes("trade")) return "swap"; + if (lower.includes("donate")) return "donate"; + if (lower.includes("fee") || lower.includes("setfee")) return "set-fees"; + if (lower.includes("sync")) return "sync-reserves"; + if (lower.includes("oracle") || lower.includes("price")) return "update-oracle"; + if (lower.includes("pause")) return "pause"; + if (lower.includes("unpause")) return "unpause"; + if (lower.includes("settle") || lower.includes("callback")) return "settle-callback"; + return "unknown"; +} + +function inferOperationKind(statement: string): AmmOperation["kind"] { + if (/\b(?:\+|\-|\*|\/|%|\+=|\-=|\*=|\/=)\b/.test(statement)) return "arithmetic"; + if (/\b(?:if|require|assert)\b/.test(statement)) return "guard"; + if (/\b(?:call|transfer|withdraw|deposit|swap|mint|burn)\b/.test(statement)) return "call"; + if (/=/.test(statement)) return "write"; + return "read"; +} + +function findMatchingBrace(source: string, openingIndex: number): number { + let depth = 0; + for (let i = openingIndex; i < source.length; i++) { + if (source[i] === "{") depth += 1; + if (source[i] === "}") { + depth -= 1; + if (depth === 0) return i; + } + } + return source.length; +} + +function extractIdentifiers(source: string, regex: RegExp): string[] { + return Array.from(source.matchAll(regex), (match) => match[0].replace(/\s*\(/g, "").trim()).filter(Boolean); +} + +function extractPrecisionScalars(source: string): string[] { + return Array.from(new Set(Array.from(source.matchAll(/(?:1e|1E)\d+|\b(?:WAD|RAY|SECONDS_PER_YEAR)\b/g), (match) => match[0]))); +} + +function extractTokenPairs(source: string): string[] { + const pairs = new Set(); + for (const match of source.matchAll(/(?:token0|token1|assetA|assetB|reserveA|reserveB|x\b|y\b)/g)) { + pairs.add(match[0]); + } + return [...pairs]; +} diff --git a/packages/core/src/amm/rule.ts b/packages/core/src/amm/rule.ts new file mode 100644 index 0000000..561cfad --- /dev/null +++ b/packages/core/src/amm/rule.ts @@ -0,0 +1,30 @@ +import type { ASTNode, Finding } from "../types"; +import { analyzeAmmSource } from "./api"; + +const AMM_SOURCE_SIGNAL = /\b(?:reserveA|reserveB|totalSupply|swapFee|protocolFee|liquidity|amountOutMin|deadline|flashSwap|donate|pool|sqrtPrice|k\b|invariant)\b/i; + +export function detectAmmAccounting(_ast: ASTNode, source: string, filePath: string): Finding[] { + if (!AMM_SOURCE_SIGNAL.test(source)) return []; + const report = analyzeAmmSource({ file: filePath, source }); + return report.files.flatMap((file) => file.findings.map((finding): Finding => ({ + id: finding.ruleId, + title: finding.title, + description: finding.description, + recommendation: finding.recommendation, + severity: finding.severity, + file: finding.location.file, + line: finding.location.line, + lineEnd: finding.location.lineEnd, + confidence: finding.confidence, + assumptions: finding.assumptions, + evidence: finding.evidence.map((evidence) => ({ + description: evidence.description + (evidence.snippet ? `: ${evidence.snippet}` : ""), + file: evidence.location.file, + line: evidence.location.line, + })), + }))); +} + +export function detectAmmInvariants(ast: ASTNode, source: string, filePath: string): Finding[] { + return detectAmmAccounting(ast, source, filePath); +} diff --git a/packages/core/src/amm/serialize.ts b/packages/core/src/amm/serialize.ts new file mode 100644 index 0000000..e4a9ace --- /dev/null +++ b/packages/core/src/amm/serialize.ts @@ -0,0 +1,91 @@ +import type { AmmAnalysisReport, AmmFinding } from "./types"; + +export function serializeAmmReportJSON(report: AmmAnalysisReport, pretty: boolean = true): string { + return `${JSON.stringify(sortValue(report), null, pretty ? 2 : 0)}\n`; +} + +export function serializeAmmReportMarkdown(report: AmmAnalysisReport): string { + const lines: string[] = [ + "# AMM Invariant, Slippage, and Liquidity Accounting Analysis", + "", + `Report schema: \`${report.schemaVersion}\` `, + `Engine version: \`${report.engineVersion}\``, + "", + "## Summary", + "", + "| Files | Contracts | Critical | High | Medium | Low | Info | Total | Truncated |", + "| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | :---: |", + `| ${report.summary.files} | ${report.summary.contracts} | ${report.summary.critical} | ${report.summary.high} | ${report.summary.medium} | ${report.summary.low} | ${report.summary.info} | ${report.summary.total} | ${report.summary.truncated ? "yes" : "no"} |`, + "", + ]; + + for (const file of report.files) { + lines.push(`## ${escapeMarkdown(file.file)}`, ""); + if (file.findings.length === 0) { + lines.push("No conservative AMM invariant or liquidity accounting risks were identified.", ""); + } + for (const finding of file.findings) renderFinding(lines, finding); + if (file.diagnostics.length > 0) { + lines.push("### Diagnostics", ""); + for (const diagnostic of file.diagnostics) { + const location = diagnostic.location ? `:${diagnostic.location.line}` : ""; + lines.push(`- **${diagnostic.code}** (${diagnostic.severity})${location}: ${escapeMarkdown(diagnostic.message)}`); + } + lines.push(""); + } + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function renderFinding(lines: string[], finding: AmmFinding): void { + lines.push( + `### ${finding.ruleId}: ${escapeMarkdown(finding.title)}`, + "", + `- **Severity:** ${finding.severity}`, + `- **Confidence:** ${finding.confidence}`, + `- **Category:** ${finding.category}`, + `- **Contract:** \`${escapeCode(finding.contract)}\``, + `- **Location:** \`${escapeCode(finding.location.file)}:${finding.location.line}:${finding.location.column}\``, + "", + escapeMarkdown(finding.description), + "", + `**Recommendation:** ${escapeMarkdown(finding.recommendation)}`, + "", + "**Evidence path:**", + "", + ); + + for (const evidence of finding.evidence) { + lines.push( + `1. ${escapeMarkdown(evidence.description)} (\`${escapeCode(evidence.location.file)}:${evidence.location.line}:${evidence.location.column}\`)`, + ); + if (evidence.snippet) lines.push(` - \`${escapeCode(evidence.snippet)}\``); + } + + if (finding.assumptions.length > 0) { + lines.push("", "**Assumptions:**", ""); + for (const assumption of finding.assumptions) lines.push(`- ${escapeMarkdown(assumption)}`); + } + + lines.push(""); +} + +function sortValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortValue); + if (value && typeof value === "object") { + const record = value as Record; + const sorted: Record = {}; + for (const key of Object.keys(record).sort()) sorted[key] = sortValue(record[key]); + return sorted; + } + return value; +} + +function escapeMarkdown(value: string): string { + return value.replace(/([\\`*_{}\[\]()<>#+.!|-])/g, "\\$1"); +} + +function escapeCode(value: string): string { + return value.replace(/`/g, "\\`").replace(/\s+/g, " ").trim(); +} diff --git a/packages/core/src/amm/types.ts b/packages/core/src/amm/types.ts new file mode 100644 index 0000000..266f346 --- /dev/null +++ b/packages/core/src/amm/types.ts @@ -0,0 +1,257 @@ +import type { Severity } from "../types"; + +export const AMM_REPORT_SCHEMA_VERSION = "1.0.0" as const; +export const AMM_CONFIG_SCHEMA_VERSION = 1 as const; + +export type AmmRuleId = + | "CP-AMM-001" + | "CP-AMM-002" + | "CP-AMM-003" + | "CP-AMM-004" + | "CP-AMM-005" + | "CP-AMM-006" + | "CP-AMM-007" + | "CP-AMM-008" + | "CP-AMM-009" + | "CP-AMM-010"; + +export type AmmVariableRole = + | "reserve-token-a" + | "reserve-token-b" + | "reserve-balance-a" + | "reserve-balance-b" + | "total-supply" + | "total-shares" + | "liquidity-balances" + | "fee-rate" + | "protocol-fee" + | "swap-fee" + | "slippage-bound" + | "deadline" + | "invariant" + | "price-bound" + | "oracle-price" + | "pause-state" + | "admin" + | "unknown"; + +export type AmmFunctionRole = + | "initialize" + | "mint-liquidity" + | "burn-liquidity" + | "swap" + | "flash-swap" + | "donate" + | "set-fees" + | "settle-callback" + | "sync-reserves" + | "update-oracle" + | "pause" + | "unpause" + | "unknown"; + +export type AmmFrameworkAdapter = + | "constant-product" + | "stable-swap" + | "weighted-pool" + | "concentrated-liquidity" + | "generic-amm" + | "none"; + +export interface AmmFrameworkAdapterDefinition { + id: Exclude; + displayName: string; + requiredStateGroups: string[][]; + requiredFunctions: string[]; + guarantees: string[]; + limitations: string[]; +} + +export interface AmmFrameworkAdapterMatch { + adapter: AmmFrameworkAdapter; + matchedState: string[]; + matchedFunctions: string[]; +} + +export interface AmmSourceLocation { + file: string; + line: number; + column: number; + lineEnd?: number; + columnEnd?: number; +} + +export interface AmmEvidence { + kind: + | "state-read" + | "state-write" + | "arithmetic" + | "branch" + | "call" + | "modifier" + | "ordering" + | "adapter" + | "absence"; + description: string; + location: AmmSourceLocation; + snippet?: string; +} + +export interface AmmStateVariable { + name: string; + typeName: string; + role: AmmVariableRole; + isMapping: boolean; + location: AmmSourceLocation; +} + +export interface AmmOperation { + order: number; + kind: "read" | "write" | "call" | "arithmetic" | "guard"; + name: string; + expression: string; + location: AmmSourceLocation; +} + +export interface AmmTransition { + name: string; + role: AmmFunctionRole; + visibility: string; + modifiers: string[]; + parameters: string[]; + reads: string[]; + writes: string[]; + calls: string[]; + operations: AmmOperation[]; + location: AmmSourceLocation; + source: string; +} + +export interface AmmContractModel { + name: string; + file: string; + adapter: AmmFrameworkAdapter; + stateVariables: AmmStateVariable[]; + transitions: AmmTransition[]; + precisionScalars: string[]; + tokenPairs: string[]; + assumptions: string[]; + location: AmmSourceLocation; +} + +export interface AmmFinding { + ruleId: AmmRuleId; + title: string; + description: string; + recommendation: string; + severity: Exclude; + confidence: "high" | "medium" | "low"; + category: + | "reserve-accounting" + | "liquidity" + | "fee-accounting" + | "slippage" + | "precision" + | "flash-swap" + | "callback" + | "invariant" + | "configuration"; + contract: string; + location: AmmSourceLocation; + evidence: AmmEvidence[]; + assumptions: string[]; +} + +export interface AmmDiagnostic { + code: + | "AMM_PARSE_ERROR" + | "AMM_SOURCE_LIMIT" + | "AMM_CONTRACT_LIMIT" + | "AMM_FUNCTION_LIMIT" + | "AMM_OPERATION_LIMIT" + | "AMM_CANCELLED" + | "AMM_CONFIG_INVALID" + | "AMM_FILE_UNREADABLE"; + severity: "error" | "warning" | "info"; + message: string; + location?: AmmSourceLocation; +} + +export interface AmmAnalysisLimits { + maxSourceBytes: number; + maxFiles: number; + maxContracts: number; + maxFunctionsPerFile: number; + maxFunctionsPerContract: number; + maxOperationsPerFunction: number; + maxFindings: number; + maxEvidencePerFinding: number; +} + +export interface AmmCancellationSignal { + readonly aborted: boolean; + readonly reason?: unknown; +} + +export interface AmmAnalysisOptions { + limits?: Partial; + signal?: AmmCancellationSignal; + includeModels?: boolean; + includeRules?: AmmRuleId[]; + excludeRules?: AmmRuleId[]; +} + +export interface AmmAnalysisConfigV1 { + schemaVersion: 1; + limits?: Partial; + includeModels?: boolean; + includeRules?: AmmRuleId[]; + excludeRules?: AmmRuleId[]; +} + +export interface AmmAnalysisConfigV0 { + version?: 0; + maxFileSize?: number; + maxIssues?: number; + rules?: AmmRuleId[]; + includeModels?: boolean; +} + +export type AmmAnalysisConfigInput = + | AmmAnalysisConfigV1 + | AmmAnalysisConfigV0 + | Record; + +export interface AmmFileAnalysis { + file: string; + findings: AmmFinding[]; + diagnostics: AmmDiagnostic[]; + models?: AmmContractModel[]; +} + +export interface AmmAnalysisReport { + schemaVersion: typeof AMM_REPORT_SCHEMA_VERSION; + engineVersion: string; + files: AmmFileAnalysis[]; + summary: { + files: number; + contracts: number; + critical: number; + high: number; + medium: number; + low: number; + info: number; + total: number; + truncated: boolean; + }; +} + +export interface AmmSourceInput { + file: string; + source: string; +} + +export interface ValidatedAmmConfig { + config: AmmAnalysisConfigV1; + diagnostics: AmmDiagnostic[]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 00c4e67..d26c570 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -210,6 +210,60 @@ export type { ValidatedStakingConfig, } from "./staking"; +// ─── AMM invariant, slippage, and liquidity accounting analysis ───────────── +export { + analyzeAmmSource, + analyzeAmmSources, + analyzeAmmFiles, + analyzeAmmProject, + collectAmmSolidityFiles, + analyzeAmmModel, + buildAmmModels, + detectAmmAccounting, + detectAmmInvariants, + serializeAmmReportJSON, + serializeAmmReportMarkdown, + DEFAULT_AMM_LIMITS, + resolveAmmLimits, + validateAmmConfig, + migrateAmmConfig, + loadAmmConfigFile, + AmmConfigError, + AmmAnalysisCancelledError, + AMM_CONFIG_SCHEMA_VERSION, + AMM_REPORT_SCHEMA_VERSION, + AMM_FRAMEWORK_ADAPTERS, + matchAmmFrameworkAdapter, + getAmmFrameworkAdapter, +} from "./amm"; +export type { + AmmAnalysisConfigInput, + AmmAnalysisConfigV0, + AmmAnalysisConfigV1, + AmmAnalysisLimits, + AmmAnalysisOptions, + AmmAnalysisReport, + AmmCancellationSignal, + AmmContractModel, + AmmDiagnostic, + AmmEvidence, + AmmFileAnalysis, + AmmFinding, + AmmFrameworkAdapter, + AmmFrameworkAdapterDefinition, + AmmFrameworkAdapterMatch, + AmmFunctionRole, + AmmOperation, + AmmRuleId, + AmmSourceInput, + AmmSourceLocation, + AmmStateVariable, + AmmTransition, + AmmVariableRole, + BuildAmmModelsResult, + ValidatedAmmConfig, +} from "./amm"; + // ─── Governance / timelock safety analysis ────────────────────────────────── export * from "./governance";