diff --git a/README.md b/README.md index 990cfb1..f3618f8 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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: @@ -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 | diff --git a/packages/sel-checker/src/environment/register-types.spec.ts b/packages/sel-checker/src/environment/register-types.spec.ts index 8e0ee87..848e261 100644 --- a/packages/sel-checker/src/environment/register-types.spec.ts +++ b/packages/sel-checker/src/environment/register-types.spec.ts @@ -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; diff --git a/packages/sel-checker/src/rules/defaults/expression-complexity.spec.ts b/packages/sel-checker/src/rules/defaults/expression-complexity.spec.ts new file mode 100644 index 0000000..f10dfac --- /dev/null +++ b/packages/sel-checker/src/rules/defaults/expression-complexity.spec.ts @@ -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 => + 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 => + 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); + }); +}); diff --git a/packages/sel-checker/src/rules/defaults/expression-complexity.ts b/packages/sel-checker/src/rules/defaults/expression-complexity.ts new file mode 100644 index 0000000..84afbc0 --- /dev/null +++ b/packages/sel-checker/src/rules/defaults/expression-complexity.ts @@ -0,0 +1,196 @@ +import { isAstNode } from "@seljs/common"; + +import { collectChildren } from "../../utils/index.js"; + +import type { SELDiagnostic } from "../../checker/checker.js"; +import type { RuleContext, SELRule } from "../types.js"; +import type { ASTNode } from "@marcbachmann/cel-js"; + +// eslint-disable-next-line import/exports-last -- interface must precede its usage +export interface ComplexityThresholds { + maxAstNodes: number; + maxDepth: number; + maxCalls: number; + maxOperators: number; + maxBranches: number; +} + +const DEFAULT_THRESHOLDS: ComplexityThresholds = { + maxAstNodes: 50, + maxDepth: 8, + maxCalls: 10, + maxOperators: 15, + maxBranches: 6, +}; + +/** Binary ops that count toward the operators metric (excludes && and ||). */ +const COUNTING_BINARY_OPS = new Set([ + "==", + "!=", + "<", + "<=", + ">", + ">=", + "+", + "-", + "*", + "/", + "%", + "in", + "[]", + "[?]", +]); + +interface Metrics { + nodes: number; + maxDepth: number; + calls: number; + operators: number; + branches: number; +} + +interface CountMetricsArgs { + node: ASTNode; + depth: number; + contractNames: Set; +} + +/** + * Recursively count all complexity metrics in a single AST pass. + */ +const countMetrics = ( + { node, depth, contractNames }: CountMetricsArgs, + metrics: Metrics, +): void => { + metrics.nodes++; + + if (depth > metrics.maxDepth) { + metrics.maxDepth = depth; + } + + // Contract calls: rcall where receiver is an id whose name is a contract + if (node.op === "rcall") { + const nodeArgs = node.args as unknown[]; + const receiverNode = nodeArgs[1]; + + if ( + isAstNode(receiverNode) && + receiverNode.op === "id" && + typeof receiverNode.args === "string" && + contractNames.has(receiverNode.args) + ) { + metrics.calls++; + } + } + + // Branches: ternary (?:), logical && and || + if (node.op === "?:" || node.op === "&&" || node.op === "||") { + metrics.branches++; + } + + // Operators: binary ops excluding && and ||; plus unary ops + if ( + COUNTING_BINARY_OPS.has(node.op) || + node.op === "!_" || + node.op === "-_" + ) { + metrics.operators++; + } + + for (const child of collectChildren(node)) { + countMetrics({ node: child, depth: depth + 1, contractNames }, metrics); + } +}; + +/** + * Factory that enforces expression complexity thresholds. + * + * Reports a diagnostic for each metric that exceeds its configured maximum. + * Setting a threshold to `Infinity` disables that metric. + */ +export const expressionComplexity = ( + thresholds?: Partial, +): SELRule => { + const resolved: ComplexityThresholds = { + ...DEFAULT_THRESHOLDS, + ...thresholds, + }; + + return { + name: "expression-complexity", + description: "Reject expressions exceeding AST complexity thresholds.", + defaultSeverity: "error", + tier: "structural", + + run(context: RuleContext): SELDiagnostic[] { + const contractNames = new Set( + context.schema.contracts.map((c) => c.name), + ); + + const metrics: Metrics = { + nodes: 0, + maxDepth: 0, + calls: 0, + operators: 0, + branches: 0, + }; + + countMetrics({ node: context.ast, depth: 0, contractNames }, metrics); + + const diagnostics: SELDiagnostic[] = []; + const span = context.expression.length; + + if (metrics.nodes > resolved.maxAstNodes) { + diagnostics.push( + context.reportAt( + 0, + span, + `Expression complexity: AST node count ${String(metrics.nodes)} exceeds maximum of ${String(resolved.maxAstNodes)}.`, + ), + ); + } + + if (metrics.maxDepth > resolved.maxDepth) { + diagnostics.push( + context.reportAt( + 0, + span, + `Expression complexity: nesting depth ${String(metrics.maxDepth)} exceeds maximum of ${String(resolved.maxDepth)}.`, + ), + ); + } + + if (metrics.calls > resolved.maxCalls) { + diagnostics.push( + context.reportAt( + 0, + span, + `Expression complexity: contract calls ${String(metrics.calls)} exceeds maximum of ${String(resolved.maxCalls)}.`, + ), + ); + } + + if (metrics.operators > resolved.maxOperators) { + diagnostics.push( + context.reportAt( + 0, + span, + `Expression complexity: operators ${String(metrics.operators)} exceeds maximum of ${String(resolved.maxOperators)}.`, + ), + ); + } + + if (metrics.branches > resolved.maxBranches) { + diagnostics.push( + context.reportAt( + 0, + span, + `Expression complexity: branches ${String(metrics.branches)} exceeds maximum of ${String(resolved.maxBranches)}.`, + ), + ); + } + + return diagnostics; + }, + }; +}; diff --git a/packages/sel-checker/src/rules/defaults/index.ts b/packages/sel-checker/src/rules/defaults/index.ts index ef625eb..f71942a 100644 --- a/packages/sel-checker/src/rules/defaults/index.ts +++ b/packages/sel-checker/src/rules/defaults/index.ts @@ -1,4 +1,5 @@ export * from "./deferred-call.js"; +export * from "./expression-complexity.js"; export * from "./no-constant-condition.js"; export * from "./no-mixed-operators.js"; export * from "./no-redundant-bool.js"; diff --git a/packages/sel-checker/src/rules/facade.ts b/packages/sel-checker/src/rules/facade.ts index eb35e97..cf5eca5 100644 --- a/packages/sel-checker/src/rules/facade.ts +++ b/packages/sel-checker/src/rules/facade.ts @@ -1,5 +1,6 @@ import { deferredCall, + expressionComplexity, noConstantCondition, noMixedOperators, noRedundantBool, @@ -40,4 +41,7 @@ export const rules = { /** Rule factory that enforces an expression evaluates to the expected CEL type. */ requireType, + + /** Factory that enforces expression complexity thresholds. */ + expressionComplexity, } as const; diff --git a/packages/sel-runtime/src/environment/environment.spec.ts b/packages/sel-runtime/src/environment/environment.spec.ts index 91a6e35..c8d59bc 100644 --- a/packages/sel-runtime/src/environment/environment.spec.ts +++ b/packages/sel-runtime/src/environment/environment.spec.ts @@ -5,8 +5,8 @@ import { describe, expect, it } from "vitest"; import { SELRuntime } from "./environment.js"; import { SELContractError, - SELEvaluationError, SELParseError, + SELTypeError, } from "../errors/index.js"; describe("src/environment/environment.ts", () => { @@ -17,7 +17,9 @@ describe("src/environment/environment.ts", () => { }); it("evaluates with context variables", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { x: "sol_int", y: "sol_int" } }), + }); const result = await env.evaluate("x + y", { x: 10n, y: 20n }); expect(result.value).toBe(30n); }); @@ -38,7 +40,9 @@ describe("src/environment/environment.ts", () => { describe("uint256 type", () => { it("evaluates BigInt arithmetic natively", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { a: "sol_int", b: "sol_int" } }), + }); const result = await env.evaluate("a + b", { a: 10n ** 18n, b: 10n ** 18n, @@ -46,8 +50,10 @@ describe("src/environment/environment.ts", () => { expect(result.value).toBe(2n * 10n ** 18n); }); - it("avoids 64-bit overflow for unregistered bigint variables", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + it("avoids 64-bit overflow for bigint variables", async () => { + const env = new SELRuntime({ + schema: buildSchema({ context: { a: "sol_int", b: "sol_int" } }), + }); const result = await env.evaluate("a + b", { a: 10n ** 21n, b: 10n ** 21n, @@ -57,7 +63,9 @@ describe("src/environment/environment.ts", () => { }); it("supports multiplication well beyond 64-bit", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { a: "sol_int", b: "sol_int" } }), + }); const result = await env.evaluate("a * b", { a: 10n ** 20n, b: 10n ** 20n, @@ -75,15 +83,19 @@ describe("src/environment/environment.ts", () => { expect(checked.type).toBe("sol_int"); const large = 2n ** 64n + 1n; - const envUntyped = new SELRuntime({ schema: buildSchema({}) }); + const envTyped = new SELRuntime({ + schema: buildSchema({ context: { a: "sol_int", b: "sol_int" } }), + }); expect( - (await envUntyped.evaluate("a == b", { a: large, b: large })) + (await envTyped.evaluate("a == b", { a: large, b: large })) .value, ).toBe(true); }); it("supports BigInt comparison operators", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { a: "sol_int", b: "sol_int" } }), + }); expect( (await env.evaluate("a > b", { a: 100n, b: 50n })).value, ).toBe(true); @@ -125,7 +137,11 @@ describe("src/environment/environment.ts", () => { describe("address type", () => { it("compares checksummed addresses", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ + context: { sender: "sol_address", owner: "sol_address" }, + }), + }); expect( ( await env.evaluate("sender == owner", { @@ -183,10 +199,10 @@ describe("src/environment/environment.ts", () => { ); }); - it("wraps evaluation errors as SELEvaluationError", async () => { + it("wraps type errors for unknown variables as SELTypeError", async () => { const env = new SELRuntime({ schema: buildSchema({}) }); await expect(env.evaluate("nonexistent_var")).rejects.toSatisfy( - (e) => e instanceof SELEvaluationError && e.cause !== undefined, + (e) => e instanceof SELTypeError && e.cause !== undefined, ); }); }); @@ -260,7 +276,7 @@ describe("src/environment/environment.ts", () => { it("accepts limits configuration", async () => { const env = new SELRuntime({ schema: buildSchema({}), - limits: { maxAstNodes: 500, maxDepth: 20 }, + limits: { maxRounds: 5, maxCalls: 50 }, }); expect((await env.evaluate("1 + 2")).value).toBe(3n); }); @@ -301,7 +317,9 @@ describe("src/environment/environment.ts", () => { }); it("parseUnits compares correctly with contract-like values", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { balance: "sol_int" } }), + }); const result = await env.evaluate( "balance >= parseUnits(500, 6)", { balance: 1000000000n }, @@ -310,7 +328,9 @@ describe("src/environment/environment.ts", () => { }); it("formatUnits scales down to double", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { balance: "sol_int" } }), + }); const result = await env.evaluate("formatUnits(balance, 6)", { balance: 1000000000n, }); @@ -318,7 +338,9 @@ describe("src/environment/environment.ts", () => { }); it("formatUnits enables readable threshold checks", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { balance: "sol_int" } }), + }); const result = await env.evaluate( "formatUnits(balance, 6) >= 1000.0", { balance: 1000000000n }, @@ -327,7 +349,9 @@ describe("src/environment/environment.ts", () => { }); it("formatUnits with fractional result", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { balance: "sol_int" } }), + }); const result = await env.evaluate("formatUnits(balance, 18)", { balance: 1500000000000000000n, }); @@ -359,7 +383,9 @@ describe("src/environment/environment.ts", () => { describe("min, max, abs, and isZeroAddress", () => { it("min returns the smaller value", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { a: "sol_int", b: "sol_int" } }), + }); const result = await env.evaluate("min(a, b)", { a: 100n, b: 50n, @@ -368,7 +394,9 @@ describe("src/environment/environment.ts", () => { }); it("max returns the larger value", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { a: "sol_int", b: "sol_int" } }), + }); const result = await env.evaluate("max(a, b)", { a: 100n, b: 50n, @@ -377,13 +405,17 @@ describe("src/environment/environment.ts", () => { }); it("abs returns absolute value of negative", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { a: "sol_int" } }), + }); const result = await env.evaluate("abs(a)", { a: -42n }); expect(result.value).toBe(42n); }); it("isZeroAddress detects zero address", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { addr: "sol_address" } }), + }); const result = await env.evaluate("isZeroAddress(addr)", { addr: "0x0000000000000000000000000000000000000000", }); @@ -391,7 +423,9 @@ describe("src/environment/environment.ts", () => { }); it("isZeroAddress returns false for non-zero", async () => { - const env = new SELRuntime({ schema: buildSchema({}) }); + const env = new SELRuntime({ + schema: buildSchema({ context: { addr: "sol_address" } }), + }); const result = await env.evaluate("isZeroAddress(addr)", { addr: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", }); diff --git a/packages/sel-runtime/src/environment/environment.ts b/packages/sel-runtime/src/environment/environment.ts index 09f1339..0687a08 100644 --- a/packages/sel-runtime/src/environment/environment.ts +++ b/packages/sel-runtime/src/environment/environment.ts @@ -1,4 +1,4 @@ -import { createRuntimeEnvironment } from "@seljs/checker"; +import { SELChecker, createRuntimeEnvironment } from "@seljs/checker"; import { normalizeContextForEvaluation } from "./context.js"; import { @@ -16,6 +16,7 @@ import { createLogger } from "../debug.js"; import { MulticallBatchError, SELContractError, + SELLintError, SELTypeError, } from "../errors/index.js"; import { MultiRoundExecutor } from "../execution/multi-round-executor.js"; @@ -28,7 +29,7 @@ import type { ExecutionMeta, } from "../execution/types.js"; import type { Environment, TypeCheckResult } from "@marcbachmann/cel-js"; -import type { CelCodecRegistry } from "@seljs/checker"; +import type { CelCodecRegistry, SELDiagnostic } from "@seljs/checker"; import type { ContractSchema, SELSchema } from "@seljs/schema"; import type { PublicClient } from "viem"; @@ -44,6 +45,7 @@ const debug = createLogger("environment"); export class SELRuntime { private readonly env: Environment; + private readonly checker: SELChecker; private readonly client?: PublicClient; private readonly schema: SELSchema; private readonly variableTypes = new Map(); @@ -89,16 +91,6 @@ export class SELRuntime { */ public constructor(config: SELRuntimeConfig) { const limits = config.limits; - const celLimits = limits - ? { - maxAstNodes: limits.maxAstNodes, - maxDepth: limits.maxDepth, - maxListElements: limits.maxListElements, - maxMapEntries: limits.maxMapEntries, - maxCallArguments: limits.maxCallArguments, - } - : undefined; - this.maxRounds = limits?.maxRounds ?? 10; this.maxCalls = limits?.maxCalls ?? 100; this.multicallOptions = config.multicall; @@ -138,13 +130,16 @@ export class SELRuntime { const { env, contractBindings, codecRegistry } = createRuntimeEnvironment( this.schema, handler, - { limits: celLimits, unlistedVariablesAreDyn: true }, ); this.env = env; this.contractBindings = contractBindings; this.codecRegistry = codecRegistry; + this.checker = new SELChecker(config.schema, { + rules: config.rules, + }); + // Populate variableTypes from schema for normalizeContextForEvaluation for (const v of this.schema.variables) { this.variableTypes.set(v.name, v.type); @@ -216,6 +211,7 @@ export class SELRuntime { normalizedContext: Record | undefined; executionVariables: Record; typeCheckResult: TypeCheckResult; + diagnostics: SELDiagnostic[]; } { const parseResult = this.env.parse(expression); @@ -245,12 +241,29 @@ export class SELRuntime { ); } + // Run lint rules via checker + const lintResult = this.checker.check(expression); + + // Enforcement: error-severity diagnostics cause throw + const lintErrors = lintResult.diagnostics.filter( + (d) => d.severity === "error", + ); + if (lintErrors.length > 0) { + throw new SELLintError(lintErrors); + } + + // Advisory: warning/info diagnostics pass through to result + const diagnostics = lintResult.diagnostics.filter( + (d) => d.severity !== "error", + ); + return { parseResult, collectedCalls, normalizedContext, executionVariables, typeCheckResult, + diagnostics, }; } @@ -360,6 +373,7 @@ export class SELRuntime { normalizedContext, executionVariables, typeCheckResult, + diagnostics, } = this.planExecution(expression, context ?? {}); let executionMeta: ExecutionMeta | undefined; @@ -411,7 +425,14 @@ export class SELRuntime { debug("evaluate: result type=%s", typeof value); - return executionMeta ? { value, meta: executionMeta } : { value }; + const evalResult: EvaluateResult = executionMeta + ? { value, meta: executionMeta } + : { value }; + if (diagnostics.length > 0) { + evalResult.diagnostics = diagnostics; + } + + return evalResult; } catch (error) { throw wrapError(error); } diff --git a/packages/sel-runtime/src/environment/types.ts b/packages/sel-runtime/src/environment/types.ts index e11f0a4..d30212a 100644 --- a/packages/sel-runtime/src/environment/types.ts +++ b/packages/sel-runtime/src/environment/types.ts @@ -1,3 +1,4 @@ +import type { SELCheckerOptions } from "@seljs/checker"; import type { SELSchema } from "@seljs/schema"; import type { Address, PublicClient } from "viem"; @@ -16,31 +17,12 @@ export interface MulticallOptions { } /** - * Limits for expression parsing and contract call execution. - * - * AST limits (`maxAstNodes`, `maxDepth`, `maxListElements`, `maxMapEntries`, - * `maxCallArguments`) are forwarded to the underlying CEL parser to constrain - * expression complexity. + * Limits for contract call execution. * * Execution limits (`maxRounds`, `maxCalls`) bound the multi-round contract * execution engine. An {@link ExecutionLimitError} is thrown when exceeded. */ export interface SELLimits { - /** Maximum number of AST nodes allowed in a parsed expression */ - maxAstNodes?: number; - - /** Maximum nesting depth of the AST */ - maxDepth?: number; - - /** Maximum number of elements in a list literal */ - maxListElements?: number; - - /** Maximum number of entries in a map literal */ - maxMapEntries?: number; - - /** Maximum number of arguments in a single function call */ - maxCallArguments?: number; - /** Maximum number of dependency-ordered execution rounds (default: 10) */ maxRounds?: number; @@ -54,7 +36,7 @@ export interface SELLimits { * All contracts and context must be declared here — the environment * cannot be mutated after construction. */ -export interface SELRuntimeConfig { +export interface SELRuntimeConfig extends SELCheckerOptions { /** SEL schema describing contracts, variables, types, functions, and macros */ schema: SELSchema; @@ -64,6 +46,6 @@ export interface SELRuntimeConfig { /** Multicall3 batching options for contract call execution */ multicall?: MulticallOptions; - /** AST parsing and execution limits */ + /** Execution limits for contract call rounds and total calls */ limits?: SELLimits; } diff --git a/packages/sel-runtime/src/errors/errors.ts b/packages/sel-runtime/src/errors/errors.ts index 007398b..e90f31d 100644 --- a/packages/sel-runtime/src/errors/errors.ts +++ b/packages/sel-runtime/src/errors/errors.ts @@ -1,5 +1,7 @@ import { SELError, SELParseError, SELTypeError } from "@seljs/common"; +import type { SELDiagnostic } from "@seljs/checker"; + // Re-export shared errors from @seljs/common export { SELError, SELParseError, SELTypeError }; @@ -92,3 +94,20 @@ export class MulticallBatchError extends SELError { this.methodName = options?.methodName; } } + +/** + * Thrown when lint rules with error severity detect violations. + * Contains the diagnostics that caused the failure. + */ +export class SELLintError extends SELError { + public readonly diagnostics: SELDiagnostic[]; + + public constructor( + diagnostics: SELDiagnostic[], + options?: { cause?: unknown }, + ) { + const messages = diagnostics.map((d) => d.message).join("; "); + super(`Expression lint failed: ${messages}`, options); + this.diagnostics = diagnostics; + } +} diff --git a/packages/sel-runtime/src/execution/types.ts b/packages/sel-runtime/src/execution/types.ts index 78f6002..bbb9338 100644 --- a/packages/sel-runtime/src/execution/types.ts +++ b/packages/sel-runtime/src/execution/types.ts @@ -1,3 +1,4 @@ +import type { SELDiagnostic } from "@seljs/checker"; import type { PublicClient } from "viem"; /** @@ -62,4 +63,7 @@ export interface EvaluateResult { /** Execution metadata, present when contract calls were executed */ meta?: ExecutionMeta; + + /** Advisory diagnostics (warnings/info) from lint rules, when non-empty */ + diagnostics?: SELDiagnostic[]; } diff --git a/packages/sel-runtime/test/integration/environment-alignment.spec.ts b/packages/sel-runtime/test/integration/environment-alignment.spec.ts index 2efcd0c..e03e8fc 100644 --- a/packages/sel-runtime/test/integration/environment-alignment.spec.ts +++ b/packages/sel-runtime/test/integration/environment-alignment.spec.ts @@ -95,13 +95,7 @@ describe("environment Alignment", () => { }); describe("invalid expressions are rejected by both", () => { - /* - * Note: only expressions rejected by BOTH environments belong here. - * `unknown_var` is NOT included because core uses unlistedVariablesAreDyn: true - * (unknown variables become dyn) while checker uses false (unknown variables fail). - * This is an intentional divergence in error strictness, not a naming alignment issue. - */ - const invalidExpressions = ["token.nonExistent()"]; + const invalidExpressions = ["token.nonExistent()", "unknown_var"]; for (const expr of invalidExpressions) { it(`both reject: ${expr}`, () => {