https://github.com/abinnovision/seljs
🔍 Query EVM smart contracts using CEL expressions!
https://github.com/abinnovision/seljs
cel-lang evm solidty
Last synced: 2 months ago
JSON representation
🔍 Query EVM smart contracts using CEL expressions!
- Host: GitHub
- URL: https://github.com/abinnovision/seljs
- Owner: abinnovision
- License: apache-2.0
- Created: 2026-03-13T11:17:24.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-04-23T17:01:26.000Z (2 months ago)
- Last Synced: 2026-04-23T17:28:06.104Z (2 months ago)
- Topics: cel-lang, evm, solidty
- Language: TypeScript
- Homepage:
- Size: 1.19 MB
- Stars: 2
- Watchers: 0
- Forks: 0
- Open Issues: 10
-
Metadata Files:
- Readme: README.md
- License: LICENSE.md
Awesome Lists containing this project
README
# SEL — Solidity Expression Language
[](https://www.npmjs.com/package/@seljs/runtime)
[](https://opensource.org/licenses/Apache-2.0)
SEL is a TypeScript library for querying EVM smart contracts using [CEL](https://cel.dev/) expressions. It provides a declarative, type-safe way to fetch and evaluate on-chain data with automatic multicall batching, dependency resolution, and atomic reads pinned to a single block.
## Packages
| Package | Description |
| ---------------------------------------------------- | ------------------------------------------------------------------- |
| [`@seljs/runtime`](./packages/sel-runtime) | Core runtime — expression evaluation, contract execution, multicall |
| [`@seljs/env`](./packages/sel-env) | Schema builder — contracts and context definitions |
| [`@seljs/checker`](./packages/sel-checker) | Expression checker — parse, type-check, and infer types |
| [`@seljs/schema`](./packages/sel-schema) | Schema types and JSON schema for editor integrations |
| [`@seljs/types`](./packages/sel-types) | Solidity ↔ CEL type system and conversions |
| [`@seljs/common`](./packages/sel-common) | Shared utilities and error base classes |
| [`@seljs/editor`](./packages/sel-editor) | CodeMirror language support (syntax, autocomplete, linting) |
| [`@seljs/editor-react`](./packages/sel-editor-react) | React component for the SEL editor |
## Installation
```bash
npm install @seljs/runtime @seljs/env
```
Peer dependencies: `typescript@^5`.
## Quick Start
```typescript
import { createSEL } from "@seljs/runtime";
import { buildSchema } from "@seljs/env";
import { createPublicClient, http, parseAbi } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http(),
});
const ERC20_ABI = parseAbi([
"function balanceOf(address account) view returns (uint256)",
"function totalSupply() view returns (uint256)",
]);
const schema = buildSchema({
contracts: {
token: {
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
},
},
context: {
user: "sol_address",
},
});
const env = createSEL({ schema, client });
// parseUnits inside the expression handles decimal scaling (USDC has 6 decimals)
const result = await env.evaluate(
"token.balanceOf(user) > parseUnits(1000, 6)",
{ user: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" },
);
console.log(result.value); // true or false
```
## Core Concepts
### CEL Expressions
SEL uses [Common Expression Language](https://cel.dev/) as its query syntax:
```typescript
// Arithmetic and comparisons
"token.balanceOf(user) > threshold";
// Logical operators
"balance > minBalance && balance < maxBalance";
// String operations
'name.startsWith("Crypto")';
// List macros
"tokens.all(t, t.balance > 0)";
// Map literals
'{"hasAccess": token.balanceOf(user) > threshold}';
```
### Built-in Functions
#### Type Casting
Contract calls return custom CEL types (`sol_int`, `sol_address`). To compare against literals, cast them with the corresponding functions:
```typescript
// Cast integer literals to sol_int
"token.balanceOf(user) > solInt(0)";
// Large constants as decimal strings
'token.balanceOf(user) >= solInt("1000000000000000000000")';
// Cast string literals to sol_address
'token.balanceOf(solAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"))';
```
#### Unit Conversion
`parseUnits` and `formatUnits` convert between human-readable values and scaled `sol_int` values, mirroring viem/ethers behavior:
```typescript
// 1000 USDC (6 decimals) → sol_int(1000000000)
"token.balanceOf(user) > parseUnits(1000, 6)";
// Decimal strings for precise amounts
'token.balanceOf(user) >= parseUnits("1.5", 18)';
// Format a sol_int back to a human-readable double
"formatUnits(token.balanceOf(user), 18) > 1.0";
```
#### Math and Address Utilities
```typescript
// min / max — return the smaller or larger of two values
"min(token.balanceOf(user), cap)";
"max(balance, solInt(0))";
// abs — absolute value
"abs(priceChange)";
// isZeroAddress — check if an address is the zero address
"isZeroAddress(token.ownerOf(tokenId))";
```
### Schema Builder
The schema is built separately from the runtime using `buildSchema` from `@seljs/env`. This decouples schema definition from execution — the same schema can be used for type-checking, editor support, and runtime evaluation.
```typescript
import { buildSchema } from "@seljs/env";
const schema = buildSchema({
contracts: {
token: { address: "0x...", abi: ERC20_ABI },
},
context: {
user: "sol_address", // Ethereum address
amount: "sol_int", // non-negative bigint (uint256)
balance: "sol_int", // bigint (int256)
active: "bool", // boolean
name: "string", // string
data: "bytes", // raw bytes
},
});
```
Context fields can also be defined as objects with a description, which is surfaced in editor tooling:
```typescript
const schema = buildSchema({
contracts: {
token: { address: "0x...", abi: ERC20_ABI },
},
context: {
user: { type: "sol_address", description: "The wallet address to check" },
threshold: {
type: "sol_int",
description: "Minimum token balance required",
},
},
});
```
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:
```typescript
// Both calls are independent — batched into 1 RPC request
const result = await env.evaluate(
"token.balanceOf(user) + nft.balanceOf(user)",
{ user: "0x..." },
);
```
### Multi-Round Execution
Dependent calls are automatically detected and executed in rounds:
```typescript
// Round 1: staking.stakedTokenId(user)
// Round 2: nft.ownerOf()
const result = await env.evaluate("nft.ownerOf(staking.stakedTokenId(user))", {
user: "0x...",
});
```
All rounds execute against the same block number, ensuring atomicity.
## Examples
### Access Control
```typescript
const schema = buildSchema({
contracts: {
membership: { address: MEMBERSHIP_ADDR, abi: ERC721_ABI },
token: { address: TOKEN_ADDR, abi: ERC20_ABI },
},
context: { user: "sol_address" },
});
const env = createSEL({ schema, client });
const { value: hasAccess } = await env.evaluate(
'membership.balanceOf(user) >= solInt(1) || token.balanceOf(user) >= solInt("1000000000000000000000")',
{ user: "0x..." },
);
```
### Dependent Contract Calls
```typescript
const schema = buildSchema({
contracts: {
staking: { address: STAKING_ADDR, abi: STAKING_ABI },
nft: { address: NFT_ADDR, abi: NFT_ABI },
},
context: { user: "sol_address" },
});
const env = createSEL({ schema, client });
// Automatically resolves: staking call first, then nft call with the result
const { value: tokenOwner } = await env.evaluate(
"nft.ownerOf(staking.stakedTokenId(user))",
{ user: "0x..." },
);
```
### Dashboard Data Fetching
```typescript
const schema = buildSchema({
contracts: {
usdc: { address: USDC_ADDR, abi: ERC20_ABI },
weth: { address: WETH_ADDR, abi: ERC20_ABI },
nft: { address: BAYC_ADDR, abi: ERC721_ABI },
},
context: { user: "sol_address" },
});
const env = createSEL({ schema, client });
// All independent calls batched into a single RPC request
const { value } = await env.evaluate(
`{
"usdcBalance": usdc.balanceOf(user),
"wethBalance": weth.balanceOf(user),
"nftCount": nft.balanceOf(user),
"hasTokens": usdc.balanceOf(user) > solInt(0) || weth.balanceOf(user) > solInt(0)
}`,
{ user: "0x..." },
);
```
## 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:
| Error | When |
| ------------------------- | ------------------------------------------------------------- |
| `SELEvaluationError` | Expression evaluation fails (undefined variables, etc.) |
| `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 |
| `MulticallBatchError` | Multicall3 batch execution fails |
## Type Mapping
| Solidity | CEL | JavaScript |
| --------------------------- | ------------- | ------------ |
| `uint8`–`uint256` | `sol_int` | `bigint` |
| `int8`–`int256` | `sol_int` | `bigint` |
| `bool` | `bool` | `boolean` |
| `address` | `sol_address` | `string` |
| `string` | `string` | `string` |
| `bytes`, `bytes1`–`bytes32` | `bytes` | `Uint8Array` |
| `T[]`, `T[N]` | `list` | `Array` |
| `tuple` | `map` | `Object` |
### Why `sol_int` and `sol_address`?
SEL registers custom CEL types instead of using the built-in `int` and `string` types:
- **`sol_int`** — Wraps all Solidity integer types (`uint8`–`uint256`, `int8`–`int256`) as a single CEL type backed by native `BigInt`. This bypasses cel-js's built-in `int` type which enforces 64-bit overflow checks — necessary because Solidity integers go up to 256 bits. Cast literals with `solInt(0)` or `solInt("1000000000000000000")`.
- **`sol_address`** — Wraps Solidity `address` as a dedicated CEL type with hex validation and lowercase normalization. This ensures address comparisons are case-insensitive (matching EVM semantics) rather than relying on plain string equality.
## Credits
- [@marcbachmann/cel-js](https://github.com/marcbachmann/cel-js) — CEL parser, type checker, and evaluator
- [viem](https://viem.sh/) — Ethereum client, ABI encoding/decoding
## License
Apache-2.0