Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ See [`.github/workflows/audit.yml`](.github/workflows/audit.yml) for a complete
| CP-115 | [SWC-115](https://swcregistry.io/docs/SWC-115) | `tx.origin` authentication | High | `tx.origin` used in `require` or access control |
| CP-101 | [SWC-101](https://swcregistry.io/docs/SWC-101) | Integer overflow / underflow | High | Arithmetic on pragma `< 0.8` without SafeMath |
| CP-104 | [SWC-104](https://swcregistry.io/docs/SWC-104) | Unchecked call return value | Medium | `.call` / `.send` return value not checked |
| CP-122 | — | Vault share-price inflation | High | Live-balance share ratio without initialization protection |
| GAS-\* | — | Gas optimizations | Gas | Storage in loops, packing, `keccak256`, etc. |

When Slither is installed, all [Slither detectors](https://github.com/crytic/slither/wiki/Detector-Documentation) are merged in with deduplication by line + title. Slither findings are prefixed with `SLITHER-`.
Expand Down
54 changes: 54 additions & 0 deletions examples/contracts/ProtectedInflationVault.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IProtectedVaultAsset {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

/// @notice Dead-share initialization prevents a first depositor owning all supply.
contract ProtectedInflationVault {
uint256 private constant MINIMUM_LIQUIDITY = 1_000;
address private constant DEAD_SHARES_RECEIVER = address(0xdead);

IProtectedVaultAsset public immutable asset;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;

constructor(IProtectedVaultAsset asset_) {
asset = asset_;
}

function totalAssets() public view returns (uint256) {
return asset.balanceOf(address(this));
}

function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
uint256 supply = totalSupply;
uint256 assetsBefore = totalAssets();

if (supply == 0) {
require(assets > MINIMUM_LIQUIDITY, "MINIMUM_DEPOSIT");
_mint(DEAD_SHARES_RECEIVER, MINIMUM_LIQUIDITY);
shares = assets - MINIMUM_LIQUIDITY;
} else {
shares = assets * totalSupply / assetsBefore;
}

require(asset.transferFrom(msg.sender, address(this), assets), "TRANSFER_FAILED");
_mint(receiver, shares);
}

function withdraw(uint256 assets, address receiver) external returns (uint256 shares) {
shares = assets * totalSupply / totalAssets();
balanceOf[msg.sender] -= shares;
totalSupply -= shares;
require(asset.transfer(receiver, assets), "TRANSFER_FAILED");
}

function _mint(address receiver, uint256 shares) internal {
balanceOf[receiver] += shares;
totalSupply += shares;
}
}
17 changes: 17 additions & 0 deletions examples/contracts/UnrelatedRatioMath.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @notice Similar proportional arithmetic, but this is not a share vault.
contract UnrelatedRatioMath {
uint256 public totalSupply;
uint256 public totalAssets;

function quote(uint256 amount) external view returns (uint256) {
return amount * totalSupply / totalAssets;
}

function updateTotals(uint256 supply, uint256 assets) external {
totalSupply = supply;
totalAssets = assets;
}
}
45 changes: 45 additions & 0 deletions examples/contracts/VulnerableInflationVault.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IInflationVaultAsset {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

/// @notice Deliberately vulnerable example for CP-122.
contract VulnerableInflationVault {
IInflationVaultAsset public immutable asset;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;

constructor(IInflationVaultAsset asset_) {
asset = asset_;
}

function totalAssets() public view returns (uint256) {
// Direct transfers change this value without minting any shares.
return asset.balanceOf(address(this));
}

function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
uint256 supply = totalSupply;
shares = supply == 0 ? assets : assets * totalSupply / totalAssets();
require(shares != 0, "ZERO_SHARES");

require(asset.transferFrom(msg.sender, address(this), assets), "TRANSFER_FAILED");
_mint(receiver, shares);
}

function withdraw(uint256 assets, address receiver) external returns (uint256 shares) {
shares = assets * totalSupply / totalAssets();
balanceOf[msg.sender] -= shares;
totalSupply -= shares;
require(asset.transfer(receiver, assets), "TRANSFER_FAILED");
}

function _mint(address receiver, uint256 shares) internal {
balanceOf[receiver] += shares;
totalSupply += shares;
}
}
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {
checkERC721Compliance,
checkERC1155Compliance,
} from "./rules/erc-compliance";
export { detectVaultInflation } from "./rules/cp122-vault-inflation";
export {
generateMarkdownReport,
generateJSONReport,
Expand All @@ -38,7 +39,6 @@ export {
} from "./report/generator";
export { diffScans, computeFingerprint } from "./diff";
export { isSlitherAvailable } from "./ast/slither";
export { enhanceFindingsWithLLM } from "./llm/enhancer";
export { loadPlugin, loadPlugins } from "./plugins";
export {
loadConfigFile,
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/rules/__tests__/cp122-vault-inflation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as fs from "fs";
import * as path from "path";
import { parseSolidity } from "../../ast/parser";
import { scan } from "../../scanner";
import { detectVaultInflation } from "../cp122-vault-inflation";

const FIXTURES_DIR = path.resolve(__dirname, "../../../../../examples/contracts");

function detectInFixture(fileName: string) {
const file = path.join(FIXTURES_DIR, fileName);
const source = fs.readFileSync(file, "utf-8");
const { ast, error } = parseSolidity(source, file);
expect(error).toBeUndefined();
expect(ast).not.toBeNull();
return detectVaultInflation(ast!, source, file);
}

describe("CP-122 vault share-price inflation", () => {
it("flags a live-balance vault with naive first-depositor share math", () => {
const findings = detectInFixture("VulnerableInflationVault.sol");

expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
id: "CP-122",
severity: "high",
});
expect(findings[0].recommendation).toContain("dead shares");
expect(findings[0].recommendation).toContain("internal accounting");
expect(findings[0].snippet).toContain("assets * totalSupply / totalAssets()");
});

it("does not flag a vault protected by minimum-liquidity dead shares", () => {
expect(detectInFixture("ProtectedInflationVault.sol")).toEqual([]);
});

it("does not flag unrelated proportional arithmetic", () => {
expect(detectInFixture("UnrelatedRatioMath.sol")).toEqual([]);
});

it("is enabled in the built-in scanner", async () => {
const file = path.join(FIXTURES_DIR, "VulnerableInflationVault.sol");
const result = await scan({
targets: [file],
useSlither: false,
useLLM: false,
useMetrics: false,
});

const finding = result.files[0].findings.find((item) => item.id === "CP-122");
expect(finding?.severity).toBe("high");
});
});
Loading
Loading