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
44 changes: 44 additions & 0 deletions docs/erc-4337-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ERC-4337 Security Rules

ChainProof includes deterministic, source-only analysis for smart accounts, EntryPoints, factories, aggregators, and paymasters. The analyzer runs as part of the normal `@chainproof/core` `scan()` pipeline and does not call a chain, bundler, RPC provider, or external service.

## Compatibility

The versioned adapter API currently recognizes EntryPoint/UserOperation patterns for `0.6`, `0.7`, and `0.8`. Use `version: "auto"` for marker-based selection or select a version explicitly when a project uses custom interfaces. Detection is conservative about unknown architectures: findings include assumptions and confidence and should be reviewed against the implementation.

## Covered risks

Rules cover UserOperation hash and replay domains, nonce validation, validation-data handling, aggregate signatures, paymaster gas/deposit/context/postOp behavior, counterfactual initialization and CREATE2 derivation, module/session authorization, upgrade authorization, and fallback dispatch.

Stable IDs use the `CP-4337-*` prefix, including `CP-4337-HASH_BINDING`, `CP-4337-ENTRYPOINT_DOMAIN`, `CP-4337-NONCE_REPLAY`, `CP-4337-PAYMASTER_POSTOP`, and related component rules. Findings contain source locations, evidence paths, assumptions, and confidence where applicable.

## Configuration

TypeScript:

```ts
import { scan } from "@chainproof/core";

const result = await scan({
targets: ["contracts/"],
useSlither: false,
useLLM: false,
useMetrics: false,
erc4337: {
version: "auto",
limits: { maxDiagnostics: 100, maxEvidenceItems: 8 },
},
});
```

CLI options are `--erc4337-version auto|0.6|0.7|0.8` and `--erc4337-max-diagnostics <number>`. The same values can be placed in `.chainproofrc.json` under `erc4337` and passed through the REST API or GitHub Action.

## Security boundaries and limitations

The analyzer uses bounded lexical and AST evidence. It cannot prove runtime storage invariants, cryptographic correctness of custom signature schemes, deployed bytecode identity, bundler behavior, or live EntryPoint deposits. Custom encodings and generated Solidity may lower confidence or produce no finding. Do not treat an empty result as proof of safety.

Source sizes, function traversal, evidence, and diagnostics are bounded. Aborted analyses return a valid, empty result rather than leaking partial provider or local-path data. Output ordering is deterministic for stable CI diffs.

## Troubleshooting

If a custom EntryPoint is misclassified, set the adapter version explicitly and inspect the finding assumptions. If a report is too noisy, use `--min-severity` or lower the ERC-4337 diagnostic budget while reviewing the highest-confidence findings first. For architectures with generated interfaces, scan the implementation and interface sources together so recognizable field and authorization evidence is available.
56 changes: 56 additions & 0 deletions examples/contracts/erc4337/SecureAccount4337.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
pragma solidity ^0.8.20;

contract SecureAccount4337 {
address public immutable entryPoint;
mapping(uint192 => uint256) private nonceSequence;
mapping(address => bool) private session;
mapping(address => uint256) private sponsorshipBudget;

modifier onlyEntryPoint() {
require(msg.sender == entryPoint, "entry point");
_;
}

function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256) external onlyEntryPoint returns (uint256) {
require(userOpHash == keccak256(abi.encode(address(this), block.chainid, entryPoint, userOp.sender, userOp.nonce, userOp.callData)), "hash");
uint192 key = uint192(userOp.nonce >> 64);
require(userOp.nonce == (key << 64) | nonceSequence[key], "nonce");
nonceSequence[key]++;
return 0;
}

function validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) external onlyEntryPoint returns (bytes memory) {
require(userOpHash != bytes32(0), "hash");
require(maxCost <= sponsorshipBudget[userOp.sender], "budget");
require(userOp.paymasterData.length >= 32, "context");
return userOp.paymasterData;
}

function postOp(bytes calldata contextData, uint256 actualGasCost) external onlyEntryPoint {
require(contextData.length >= 32, "context");
require(actualGasCost <= sponsorshipBudget[address(this)], "cost");
sponsorshipBudget[address(this)] -= actualGasCost;
}

function initialize(address expectedSession) external {
require(!session[expectedSession], "initialized");
session[expectedSession] = true;
}
}

struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
uint256 callGasLimit;
uint256 verificationGasLimit;
uint256 preVerificationGas;
uint256 maxFeePerGas;
uint256 maxPriorityFeePerGas;
address paymaster;
uint256 paymasterVerificationGasLimit;
uint256 paymasterPostOpGasLimit;
bytes paymasterData;
bytes signature;
}
50 changes: 50 additions & 0 deletions examples/contracts/erc4337/VulnerableAccount4337.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
pragma solidity ^0.8.20;

contract VulnerableAccount4337 {
mapping(address => bool) public session;
bytes public context;

function validateUserOp(PackedUserOperation calldata userOp, bytes32, uint256) external returns (uint256) {
if (session[userOp.sender]) {
context = userOp.paymasterData;
}
return 0;
}

function validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32, uint256) external returns (bytes memory) {
context = userOp.paymasterData;
return context;
}

function postOp(bytes calldata contextData, uint256 actualGasCost) external {
(bool ok,) = address(this).call(contextData);
require(ok);
}

function execute(bytes calldata callData) external {
(bool ok,) = address(this).call(callData);
require(ok);
}

fallback() external payable {
(bool ok,) = address(this).call(msg.data);
require(ok);
}
}

struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
uint256 callGasLimit;
uint256 verificationGasLimit;
uint256 preVerificationGas;
uint256 maxFeePerGas;
uint256 maxPriorityFeePerGas;
address paymaster;
uint256 paymasterVerificationGasLimit;
uint256 paymasterPostOpGasLimit;
bytes paymasterData;
bytes signature;
}
22 changes: 22 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
loadPlugins,
loadConfigFile,
mergePluginsFromConfig,
mergeERC4337ConfigFromConfig,
generateThreatModel,
generateMarkdownThreatModel,
generateJSONThreatModel,
Expand Down Expand Up @@ -90,6 +91,8 @@ program
)
.option("--format <format>", "Output format: table|json|markdown", "table")
.option("--output <file>", "Write report to file instead of stdout")
.option("--erc4337-version <version>", "ERC-4337 adapter version: auto|0.6|0.7|0.8", "auto")
.option("--erc4337-max-diagnostics <number>", "Maximum ERC-4337 diagnostics per file", "100")
.option(
"--plugin <plugin>",
"Load a custom plugin (can be used multiple times)",
Expand All @@ -111,6 +114,8 @@ program
format: string;
output?: string;
plugin: string[];
erc4337Version: string;
erc4337MaxDiagnostics: string;
},
) => {

Expand Down Expand Up @@ -146,6 +151,7 @@ program

// Load plugins from CLI or config file
let plugins = [];
let configuredERC4337: ScanConfig["erc4337"] | undefined;
if (opts.plugin.length > 0) {
plugins = loadPlugins(opts.plugin);
} else {
Expand All @@ -162,6 +168,17 @@ program
configFile,
);
plugins = merged.plugins || [];
configuredERC4337 = mergeERC4337ConfigFromConfig(
{
targets,
useSlither,
useLLM,
useMetrics,
apiKey,
minSeverity: opts.minSeverity as ScanConfig["minSeverity"],
},
configFile,
).erc4337;
}

console.log(
Expand All @@ -186,6 +203,10 @@ program
minSeverity: opts.minSeverity as ScanConfig["minSeverity"],
outputFormat: opts.format as ScanConfig["outputFormat"],
plugins,
erc4337: configuredERC4337 ?? {
version: opts.erc4337Version as "auto" | "0.6" | "0.7" | "0.8",
limits: { maxDiagnostics: Number(opts.erc4337MaxDiagnostics) },
},
};

let result;
Expand Down Expand Up @@ -471,6 +492,7 @@ program
outputFormat: "markdown",
output: "audit-report.md",
plugins: [],
erc4337: { version: "auto", limits: { maxDiagnostics: 100 } },
};
const configPath = path.join(process.cwd(), ".chainproofrc.json");
if (fs.existsSync(configPath)) {
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/__tests__/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const SECURE_PATH = path.resolve(
__dirname,
"../../../../examples/contracts/SecureVault.sol"
);
const ERC4337_PATH = path.resolve(
__dirname,
"../../../../examples/contracts/erc4337/VulnerableAccount4337.sol"
);

describe("scan() — integration", () => {
it("returns a valid ScanResult structure", async () => {
Expand Down Expand Up @@ -124,4 +128,16 @@ describe("scan() — integration", () => {
const result = await scan({ targets: [dir], useSlither: false, useLLM: false, useMetrics: false });
expect(result.files.length).toBeGreaterThan(0);
});

it("registers ERC-4337 rules in the standard scan pipeline", async () => {
const result = await scan({
targets: [ERC4337_PATH],
useSlither: false,
useLLM: false,
useMetrics: false,
});
const ids = result.files.flatMap((file) => file.findings.map((finding) => finding.id));
expect(ids).toContain("CP-4337-NONCE_REPLAY");
expect(ids).toContain("CP-4337-PAYMASTER_POSTOP");
});
});
11 changes: 11 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import * as fs from "fs";
import * as path from "path";
import { loadPlugins } from "./plugins";
import type { ScanConfig, SlitherConfig } from "./types";
import type { ERC4337AnalysisOptions } from "./erc4337/types";

export interface ChainProofConfig {
plugins?: string[];
slither?: SlitherConfig;
erc4337?: ERC4337AnalysisOptions;
[key: string]: unknown;
}

Expand Down Expand Up @@ -116,3 +118,12 @@ export function mergeSlitherConfigFromConfig(
slither: configFile.slither,
};
}

/** Merge versioned ERC-4337 settings while preserving explicit scan options. */
export function mergeERC4337ConfigFromConfig(
config: ScanConfig,
configFile?: ChainProofConfig | null,
): ScanConfig {
if (!configFile?.erc4337 || config.erc4337) return config;
return { ...config, erc4337: configFile.erc4337 };
}
47 changes: 47 additions & 0 deletions packages/core/src/erc4337/__tests__/analyzer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as fs from "fs";
import * as path from "path";
import { parseSolidity } from "../../ast/parser";
import { analyzeERC4337, detectERC4337 } from "../analyzer";

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

function readFixture(name: string): { source: string; file: string; ast: any } {
const file = path.join(FIXTURES, name);
const source = fs.readFileSync(file, "utf8");
const parsed = parseSolidity(source, file);
expect(parsed.ast).not.toBeNull();
return { source, file, ast: parsed.ast };
}

describe("ERC-4337 analyzer", () => {
it("models versioned UserOperations and detects vulnerable paymaster paths", () => {
const fixture = readFixture("VulnerableAccount4337.sol");
const analysis = analyzeERC4337(fixture.ast, fixture.source, fixture.file);
expect(analysis.protocol).toBe("erc-4337");
expect(analysis.schemaVersion).toBe("erc4337-analysis-1");
expect(analysis.version).toBe("0.8");
expect(analysis.userOperation?.fields.length).toBeGreaterThan(10);
expect(analysis.diagnostics.map((item) => item.code)).toEqual(
expect.arrayContaining(["AA003_NONCE_REPLAY", "AA007_PAYMASTER_DEPOSIT", "AA008_PAYMASTER_POSTOP"]),
);
});

it("keeps secure validation free of nonce and paymaster findings", () => {
const fixture = readFixture("SecureAccount4337.sol");
const findings = detectERC4337(fixture.ast, fixture.source, fixture.file);
expect(findings.map((finding) => finding.id)).not.toEqual(
expect.arrayContaining(["CP-4337-NONCE_REPLAY", "CP-4337-PAYMASTER_LIMIT", "CP-4337-PAYMASTER_POSTOP"]),
);
});

it("is deterministic and honors diagnostic bounds", () => {
const fixture = readFixture("VulnerableAccount4337.sol");
const options = { limits: { maxDiagnostics: 2, maxEvidenceItems: 1 } };
const first = analyzeERC4337(fixture.ast, fixture.source, fixture.file, options);
const second = analyzeERC4337(fixture.ast, fixture.source, fixture.file, options);
expect(JSON.stringify(first)).toBe(JSON.stringify(second));
expect(first.diagnostics).toHaveLength(2);
expect(first.truncated).toBe(true);
expect(first.diagnostics.every((item) => item.evidence.length <= 1)).toBe(true);
});
});
70 changes: 70 additions & 0 deletions packages/core/src/erc4337/adapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ERC4337Version } from "./types";

export interface ERC4337Adapter {
version: ERC4337Version;
userOperationTypeNames: readonly string[];
entryPointFunctionNames: readonly string[];
paymasterFunctionNames: readonly string[];
packedUserOperation: boolean;
supportsAggregatedOperations: boolean;
markers: readonly RegExp[];
}

const ADAPTERS: readonly ERC4337Adapter[] = [
{
version: "0.6",
userOperationTypeNames: ["UserOperation"],
entryPointFunctionNames: ["handleOps", "handleAggregatedOps", "getUserOpHash"],
paymasterFunctionNames: ["validatePaymasterUserOp", "postOp"],
packedUserOperation: false,
supportsAggregatedOperations: true,
markers: [/IEntryPoint/, /UserOperation\s+(?:calldata|memory)/],
},
{
version: "0.7",
userOperationTypeNames: ["PackedUserOperation"],
entryPointFunctionNames: ["handleOps", "handleAggregatedOps", "getUserOpHash"],
paymasterFunctionNames: ["validatePaymasterUserOp", "postOp"],
packedUserOperation: true,
supportsAggregatedOperations: true,
markers: [/PackedUserOperation/, /IEntryPoint\s*\{/],
},
{
version: "0.8",
userOperationTypeNames: ["PackedUserOperation"],
entryPointFunctionNames: ["handleOps", "handleAggregatedOps", "getUserOpHash"],
paymasterFunctionNames: ["validatePaymasterUserOp", "postOp"],
packedUserOperation: true,
supportsAggregatedOperations: true,
markers: [/PackedUserOperation/, /postOp\s*\(/, /validatePaymasterUserOp/],
},
];

export function getERC4337Adapter(version: ERC4337Version): ERC4337Adapter {
return ADAPTERS.find((adapter) => adapter.version === version) ?? ADAPTERS[1];
}

export function listERC4337Adapters(): readonly ERC4337Adapter[] {
return ADAPTERS;
}

export function detectERC4337Version(source: string): ERC4337Version {
const scored = ADAPTERS.map((adapter) => ({
adapter,
score: adapter.markers.filter((marker) => marker.test(source)).length,
}));
scored.sort((left, right) => right.score - left.score || left.adapter.version.localeCompare(right.adapter.version));
return scored[0]?.score ? scored[0].adapter.version : "0.7";
}

export function adapterSupportsFunction(version: ERC4337Version, functionName: string): boolean {
const adapter = getERC4337Adapter(version);
return [...adapter.entryPointFunctionNames, ...adapter.paymasterFunctionNames].includes(functionName);
}

export function canonicalUserOperationFields(version: ERC4337Version): readonly string[] {
if (version === "0.6") {
return ["sender", "nonce", "initCode", "callData", "callGasLimit", "verificationGasLimit", "preVerificationGas", "maxFeePerGas", "maxPriorityFeePerGas", "paymasterAndData", "signature"];
}
return ["sender", "nonce", "initCode", "callData", "accountGasLimits", "preVerificationGas", "gasFees", "paymasterAndData", "signature"];
}
Loading