Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions examples/contracts/amm/SecureAMMProtocol.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
53 changes: 53 additions & 0 deletions examples/contracts/amm/VulnerableAMMProtocol.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
43 changes: 43 additions & 0 deletions packages/core/src/amm/__tests__/analyzer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
67 changes: 67 additions & 0 deletions packages/core/src/amm/adapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type {
AmmContractModel,
AmmFrameworkAdapter,
AmmFrameworkAdapterDefinition,
AmmFrameworkAdapterMatch,
} from "./types";

export const AMM_FRAMEWORK_ADAPTERS: ReadonlyArray<AmmFrameworkAdapterDefinition> = [
{
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<AmmContractModel, "stateVariables" | "transitions">): 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<AmmContractModel, "stateVariables" | "transitions">): AmmFrameworkAdapter {
return matchAmmFrameworkAdapter(model).adapter;
}
Loading