SEL is a TypeScript library for querying EVM smart contracts using CEL 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.
| Package | Description |
|---|---|
@seljs/runtime |
Core runtime β expression evaluation, contract execution, multicall |
@seljs/env |
Schema builder β contracts and context definitions |
@seljs/checker |
Expression checker β parse, type-check, and infer types |
@seljs/schema |
Schema types and JSON schema for editor integrations |
@seljs/types |
Solidity β CEL type system and conversions |
@seljs/common |
Shared utilities and error base classes |
@seljs/editor |
CodeMirror language support (syntax, autocomplete, linting) |
@seljs/editor-react |
React component for the SEL editor |
npm install @seljs/runtime @seljs/envPeer dependencies: typescript@^5.
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 falseSEL uses Common Expression Language as its query syntax:
// 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}';Contract calls return custom CEL types (sol_int, sol_address). To compare against literals, cast them with the corresponding functions:
// 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"))';parseUnits and formatUnits convert between human-readable values and scaled sol_int values, mirroring viem/ethers behavior:
// 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";// 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))";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.
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:
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 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
SELLintErrorbefore execution - Warning / Info β advisory rules that surface in
result.diagnosticswithout blocking execution
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
}
}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.
| 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 |
Independent contract calls within the same expression are batched into a single Multicall3 RPC call:
// Both calls are independent β batched into 1 RPC request
const result = await env.evaluate(
"token.balanceOf(user) + nft.balanceOf(user)",
{ user: "0x..." },
);Dependent calls are automatically detected and executed in rounds:
// Round 1: staking.stakedTokenId(user)
// Round 2: nft.ownerOf(<result from round 1>)
const result = await env.evaluate("nft.ownerOf(staking.stakedTokenId(user))", {
user: "0x...",
});All rounds execute against the same block number, ensuring atomicity.
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..." },
);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..." },
);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..." },
);SELLimits controls how many resources the runtime can consume during contract call execution:
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 instead β it rejects overly complex expressions before any on-chain calls happen.
When evaluating user-authored expressions (e.g., from a frontend editor), use both layers together:
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.
All errors extend SELError. Catch specific types for granular handling:
| Error | When |
|---|---|
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 |
MulticallBatchError |
Multicall3 batch execution fails |
| 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<T> |
Array |
tuple |
map |
Object |
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 nativeBigInt. This bypasses cel-js's built-ininttype which enforces 64-bit overflow checks β necessary because Solidity integers go up to 256 bits. Cast literals withsolInt(0)orsolInt("1000000000000000000").sol_addressβ Wraps Solidityaddressas 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.
- @marcbachmann/cel-js β CEL parser, type checker, and evaluator
- viem β Ethereum client, ABI encoding/decoding
Apache-2.0