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
110 changes: 110 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,66 @@ const schema = buildSchema({

Context keys are mapped directly to CEL types for type-checking and runtime evaluation.

### Lint Rules

Lint rules analyze the expression AST **before** any on-chain calls happen. They are passed via the `rules` option and come in two severities:

- **Error** — enforcement rules that cause the runtime to throw `SELLintError` before execution
- **Warning / Info** — advisory rules that surface in `result.diagnostics` without blocking execution

```typescript
import { createSEL } from "@seljs/runtime";
import { expressionComplexity, requireType, rules } from "@seljs/checker";

const env = createSEL({
schema,
client,
rules: [
// Enforcement — throws SELLintError if violated
requireType("bool"),
expressionComplexity({ maxAstNodes: 50, maxDepth: 8 }),

// Advisory — warnings/info in result.diagnostics
...rules.builtIn,
],
});

try {
const result = await env.evaluate("token.balanceOf(user) > solInt(0)", {
user: "0x...",
});
console.log(result.diagnostics); // advisory warnings, if any
} catch (error) {
if (error instanceof SELLintError) {
console.log(error.diagnostics); // which rules were violated
}
}
```

#### Expression Complexity

The `expressionComplexity` rule measures five AST metrics. Each can be configured independently — set to `Infinity` to disable a metric:

| Metric | What it measures | Default |
| -------------- | ------------------------------------------------ | ------- | ------------ | --- |
| `maxAstNodes` | Total AST node count | 50 |
| `maxDepth` | Maximum nesting depth | 8 |
| `maxCalls` | Contract method call nodes in the AST | 10 |
| `maxOperators` | Arithmetic, comparison, and membership operators | 15 |
| `maxBranches` | Ternary (`?:`) and logical (`&&`, ` | | `) branching | 6 |

`maxOperators` and `maxBranches` are distinct — `&&`/`||` count as branches only, not operators.

#### Built-in Advisory Rules

| Rule | Severity | What it catches |
| ----------------------- | -------- | -------------------------------------------- |
| `no-redundant-bool` | warning | `x == true` — simplify to `x` |
| `no-constant-condition` | warning | `true && x` — likely a mistake |
| `no-self-comparison` | warning | `x == x` — always true |
| `no-mixed-operators` | info | `a && b \|\| c` — add parens for clarity |
| `deferred-call` | info | Contract call can't be batched via multicall |

### Automatic Multicall Batching

Independent contract calls within the same expression are batched into a single Multicall3 RPC call:
Expand Down Expand Up @@ -270,6 +330,55 @@ const { value } = await env.evaluate(
);
```

## Execution Limits

`SELLimits` controls how many resources the runtime can consume during contract call execution:

```typescript
const env = createSEL({
schema,
client,
limits: {
maxRounds: 10, // max dependency-ordered execution rounds (default: 10)
maxCalls: 100, // max total contract calls across all rounds (default: 100)
},
});
```

These are hard limits — exceeding them throws `ExecutionLimitError`. They protect against runaway execution when expressions contain deeply chained or recursive contract calls.

For static complexity analysis (AST node count, nesting depth, etc.), use the [`expressionComplexity` lint rule](#expression-complexity) instead — it rejects overly complex expressions before any on-chain calls happen.

### Recommended Defaults for Untrusted Input

When evaluating user-authored expressions (e.g., from a frontend editor), use both layers together:

```typescript
import { expressionComplexity, requireType, rules } from "@seljs/checker";

const env = createSEL({
schema,
client,
limits: {
maxRounds: 5, // tighter than default — limits chained RPC calls
maxCalls: 20, // limits total on-chain calls
},
rules: [
requireType("bool"), // expressions must resolve to a boolean
expressionComplexity({
maxAstNodes: 40, // reject overly large expressions
maxDepth: 6, // prevent deeply nested logic
maxCalls: 8, // limit contract call complexity
maxOperators: 12, // cap arithmetic/comparison density
maxBranches: 4, // limit branching complexity
}),
...rules.builtIn, // no-redundant-bool, no-constant-condition, etc.
],
});
```

**Execution limits** are a safety net that catches runaway execution at the RPC level. **Lint rules** reject bad expressions early with actionable error messages — before any gas is spent.

## Error Handling

All errors extend `SELError`. Catch specific types for granular handling:
Expand All @@ -279,6 +388,7 @@ All errors extend `SELError`. Catch specific types for granular handling:
| `SELParseError` | Invalid CEL expression syntax |
| `SELEvaluationError` | Expression evaluation fails (undefined variables, etc.) |
| `SELTypeError` | Type checking fails |
| `SELLintError` | Lint rule with error severity violated (`.diagnostics`) |
| `SELContractError` | Contract call fails (includes `.contractName`, `.methodName`) |
| `CircularDependencyError` | Circular dependency in call graph |
| `ExecutionLimitError` | `maxRounds` or `maxCalls` exceeded |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { describe, expect, it, vi } from "vitest";
import { registerSolidityTypes } from "./register-types.js";

function createCheckerEnv() {
const env = new Environment({ unlistedVariablesAreDyn: true });
const env = new Environment();
registerSolidityTypes(env);

return env;
Expand Down
226 changes: 226 additions & 0 deletions packages/sel-checker/src/rules/defaults/expression-complexity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { describe, expect, it } from "vitest";

import { expressionComplexity } from "./expression-complexity.js";
import { SELChecker } from "../../checker/checker.js";

import type { ContractSchema, MethodSchema, SELSchema } from "@seljs/schema";

/**
* Creates a method schema for tests.
* The `abi` field is required by MethodSchema but unused by the checker.
*/
const method = (m: Omit<MethodSchema, "abi">): MethodSchema =>
m as MethodSchema;

/**
* Creates a contract schema for tests.
* The `address` field is required by ContractSchema but unused by the checker.
*/
const contract = (c: Omit<ContractSchema, "address">): ContractSchema =>
c as ContractSchema;

const TEST_SCHEMA: SELSchema = {
version: "1.0.0",
contracts: [
contract({
name: "token",
methods: [
method({
name: "balanceOf",
params: [{ name: "account", type: "sol_address" }],
returns: "sol_int",
}),
method({ name: "totalSupply", params: [], returns: "sol_int" }),
method({
name: "allowance",
params: [
{ name: "owner", type: "sol_address" },
{ name: "spender", type: "sol_address" },
],
returns: "sol_int",
}),
],
}),
],
variables: [
{ name: "user", type: "sol_address" },
{ name: "threshold", type: "sol_int" },
],
types: [],
functions: [],
macros: [],
};

describe("expressionComplexity rule", () => {
it("simple expression produces no diagnostic", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [expressionComplexity()],
});
const result = checker.check("1 + 2");

expect(result.diagnostics).toHaveLength(0);
});

it("expression exceeding maxAstNodes triggers error", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [expressionComplexity({ maxAstNodes: 3 })],
});

/* "1 + 2 + 3 + 4" has many nodes (7+) */
const result = checker.check("1 + 2 + 3 + 4");

const msgs = result.diagnostics.map((d) => d.message);
expect(msgs.some((m) => m.includes("AST node count"))).toBe(true);
expect(result.diagnostics[0]?.severity).toBe("error");
});

it("deeply nested ternary triggers maxDepth", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [expressionComplexity({ maxDepth: 1 })],
});

/*
* root ternary at depth 0, inner ternary children at depth 1, leaf at depth 2
* maxDepth recorded will be 2, which exceeds limit of 1
*/
const result = checker.check("true ? (true ? 1 : 2) : 3");

const msgs = result.diagnostics.map((d) => d.message);
expect(msgs.some((m) => m.includes("nesting depth"))).toBe(true);
});

it("multiple contract calls trigger maxCalls", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [expressionComplexity({ maxCalls: 1 })],
});

/* Two contract calls */
const result = checker.check("token.totalSupply() > token.totalSupply()");

const msgs = result.diagnostics.map((d) => d.message);
expect(msgs.some((m) => m.includes("contract calls"))).toBe(true);
});

it("many operators trigger maxOperators (&&/|| NOT counted)", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [expressionComplexity({ maxOperators: 2 })],
});

/* 3 arithmetic operators, 1 logical (&&) — && should NOT count */
const result = checker.check("1 + 2 + 3 > 0 && true");

const msgs = result.diagnostics.map((d) => d.message);
expect(msgs.some((m) => m.includes("operators"))).toBe(true);
});

it("&&/|| are NOT counted as operators", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [expressionComplexity({ maxOperators: 2, maxBranches: 100 })],
});

/* Only && and ||, no other operators */
const result = checker.check("true && false || true");

const msgs = result.diagnostics.map((d) => d.message);
expect(msgs.some((m) => m.includes("operators"))).toBe(false);
});

it("many branches (&&/||/ternary) trigger maxBranches", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [expressionComplexity({ maxBranches: 2 })],
});

/* 3 branches: &&, ||, ?: */
const result = checker.check("true && false || (true ? 1 : 2) == 1");

const msgs = result.diagnostics.map((d) => d.message);
expect(msgs.some((m) => m.includes("branches"))).toBe(true);
});

it("multiple thresholds exceeded produces multiple diagnostics", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [
expressionComplexity({
maxAstNodes: 1,
maxDepth: 0,
maxOperators: 0,
}),
],
});
const result = checker.check("1 + 2");

/* Should have diagnostics for nodes, depth, and operators */
expect(result.diagnostics.length).toBeGreaterThanOrEqual(3);
});

it("custom thresholds override defaults", () => {
/* With very high thresholds, nothing should trigger */
const checker = new SELChecker(TEST_SCHEMA, {
rules: [
expressionComplexity({
maxAstNodes: 1000,
maxDepth: 1000,
maxCalls: 1000,
maxOperators: 1000,
maxBranches: 1000,
}),
],
});
const result = checker.check(
"token.totalSupply() > threshold && token.totalSupply() > 0",
);

expect(result.diagnostics).toHaveLength(0);
});

it("setting threshold to Infinity disables that metric", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [
expressionComplexity({
maxAstNodes: Infinity,
maxDepth: Infinity,
maxCalls: Infinity,
maxOperators: Infinity,
maxBranches: Infinity,
}),
],
});

/* Even a complex expression should produce no diagnostics */
const result = checker.check(
"token.totalSupply() > threshold && token.totalSupply() > 0",
);

expect(result.diagnostics).toHaveLength(0);
});

it("diagnostic message contains actual and limit values", () => {
const checker = new SELChecker(TEST_SCHEMA, {
rules: [expressionComplexity({ maxAstNodes: 3 })],
});
const result = checker.check("1 + 2 + 3 + 4");

const nodeMsg = result.diagnostics.find((d) =>
d.message.includes("AST node count"),
);
expect(nodeMsg).toBeDefined();

/* Message should mention the actual count and the maximum */
expect(nodeMsg?.message).toMatch(/\d+ exceeds maximum of 3/);
});

it("diagnostic spans full expression (from: 0, to: expression.length)", () => {
const expr = "1 + 2 + 3 + 4";
const checker = new SELChecker(TEST_SCHEMA, {
rules: [expressionComplexity({ maxAstNodes: 3 })],
});
const result = checker.check(expr);

const diag = result.diagnostics.find((d) =>
d.message.includes("AST node count"),
);
expect(diag).toBeDefined();
expect(diag?.from).toBe(0);
expect(diag?.to).toBe(expr.length);
});
});
Loading
Loading