Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Predicates and the expression language

Every predicate a monitor carries reduces one decoded occurrence to a single yes-or-no answer, deciding whether it becomes a match. The whole language it’s written in lives in crates/blockwatcher-expr: a lexer, a recursive-descent parser, a type-checker that runs at write time against a monitor’s selector schemas, and a total evaluator that runs at decode time and can never error. This page enumerates the grammar from the parser itself, explains the type system it checks against, and walks compilation and evaluation end to end. Every predicate shown below was checked against the parser and type-checker described here, against a schema this page defines in The example schema below.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    sources["Sources<br/>evm-rpc · evm-mempool"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,storage,api,metrics,engine,decoder,gate dim
class matcher focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • A predicate compiles once, at write time, into an AST checked against the monitor’s schema; evaluation at decode time is total and can never error.
  • The type system groups values into families (Int, Str, address/bytes, Bool) rather than exact types, so a heterogeneous field can carry more than one family at once.
  • Evaluation is three-valued (True, False, Unknown) with Kleene logic for &&/||, and a predicate treats Unknown the same as False.
  • An unknown field or namespace gets a bounded edit-distance suggestion within distance 3; beyond that there is no suggestion at all.
  • expr is the one matcher this repository ships, selected once for the whole process rather than per monitor.
  • A predicate is per-event. Counting, windows, and “already alerted” are a gate, not an expression.

Compilation: what happens when a monitor is written

Compilation happens once, at write time; evaluation happens on every decoded event, against the artifact compilation already produced:

flowchart LR
    subgraph write["write time, once"]
        text["predicate text"] --> parse["parser::parse"] --> ast["Ast"]
        ast --> check["typecheck::check<br/>against SchemaSet"]
        check --> compiled["compiled Predicate"]
    end
    subgraph hot["decode time, every event"]
        compiled --> eval["eval::evaluate(ast, event)"]
        eval --> truth["True / False / Unknown"]
    end

Predicate::compile (crates/blockwatcher-expr/src/lib.rs) does exactly two things, in order: parser::parse turns the source text into an Ast, and typecheck::check walks that tree against the monitor’s SchemaSet, resolving every field path and admitting or rejecting every operator’s operand types. Either stage can fail with a PredicateError, and either failure is what a monitor write rejects with. A predicate never reaches storage unparsed or untyped. A Predicate that compiled successfully cannot fail to evaluate: matches (lib.rs) is eval::evaluate(&self.ast, event) == Truth::True, a total function over the type-checked tree. This is also why the same text can mean two different, unrelated things depending on which monitor it’s attached to: the schema it type-checks against comes entirely from that monitor’s own selectors, never from a shared global vocabulary. The expr module (crates/blockwatcher-expr/src/matcher.rs) is the one Matcher this repository ships, and blockwatcher.toml’s matcher field names it for the entire process at boot: one choice for every network and every monitor the deployment runs, not a per-monitor setting. Nothing about the port boundary itself limits a deployment to this one engine; it is simply the only implementation in the module catalog blockwatcher ships (see Modules). The pipeline covers where in a running pipeline this compiled predicate actually runs.

The example schema

Every predicate example on this page type-checks against one small schema: a spec declaring two ERC-20-style events plus one made-up event for string and array examples, alongside the tx/block/log namespaces the evm decoder’s namespaces function attaches to every compiled spec (crates/blockwatcher-evm/src/decoder/compile.rs):

args (from the selected event)
  Transfer:  from address, to address, value uint256
  Approval:  owner address, spender address, value uint256
  Registered: owner address, name string, tags string[]
tx:    hash bytes, index uint, status uint, from address, to address, value uint
block: number uint, hash bytes, timestamp uint
log:   address address, index uint

args.* is always derived from whichever event or function the monitor’s selectors decoded: it is never declared separately. tx.*, block.*, and log.* are the same three namespaces on every evm spec, and which of their fields an occurrence actually carries depends on the selector that produced it: a log-decoded event’s tx.status is always the constant 1 (a log exists only in a transaction that succeeded) and it never carries tx.from/tx.to/tx.value, while a function-call-decoded occurrence carries those but only carries tx.status/tx.index/block.* once the transaction is mined, never on evm-mempool. A predicate reading a field this occurrence doesn’t carry resolves Unknown, covered under Evaluation below.

Syntax by example

args.value > 1_000_000e6

args.value is declared uint256, so it’s an integer; 1_000_000e6 is token-decimal notation: one million at six decimals, expanded exactly to 1000000000000 at parse time by expand_token_decimal (crates/blockwatcher-expr/src/lexer.rs). This reads “more than one million whole units of a six-decimal token.”

tx.from != 0x0000000000000000000000000000000000000000 && args.value > 0

tx.from is declared address; the hex literal is a byte string compared byte-for-byte, regardless of length: lex_hex (lexer.rs) doesn’t require 20 bytes or any other specific length, only an even digit count. && requires both sides to admit Bool, which both comparisons do.

"promo" in args.tags

args.tags is declared string[]; in’s left operand is a scalar, its right operand here is a path resolving to an array whose element type (Str) intersects the left operand’s family.

args.name contains "USDC" || args.name starts_with "test-"

Both contains and starts_with require Str on both sides; args.name is declared string.

args.value % 1_000_000 == 0

% requires both operands to admit Int; 1_000_000 is a plain integer literal (no exponent), and dividing or taking the modulus of a literal zero is rejected at write time by bin_type_arithmetic (crates/blockwatcher-expr/src/typecheck/mod.rs). A runtime zero divisor, by contrast, resolves to Unknown rather than panicking (covered below).

One rejection worth showing alongside the working examples, because it’s the language’s one surprising parse rule: comparison operators don’t chain.

1 < args.value < 100

This is refused at parse time with comparisons do not chain; parenthesize (crates/blockwatcher-expr/src/parser.rs:262-282) rather than silently reading as either (1 < args.value) && (args.value < 100) or the mathematically different (1 < args.value) < 100. Write it as args.value > 1 && args.value < 100 instead.

Grammar and precedence

The parser is recursive descent, one function per precedence level, from loosest binding to tightest (crates/blockwatcher-expr/src/parser.rs):

  1. ||
  2. &&
  3. ! (prefix; its operand is itself parsed at this level, so ! chains, and it wraps an entire comparison rather than binding inside one: !a.x == 1 parses as !(a.x == 1), not (!a.x) == 1)
  4. Comparisons: == != < <= > >= in starts_with ends_with contains (non-chaining, exactly as shown above)
  5. + -
  6. * / %
  7. Unary -
  8. Atoms: integer/hex/string/boolean literals, field paths, (...)

in’s right-hand side is special-cased in the grammar itself, inside parse_in_rhs (parser.rs): it accepts only a literal list ([...], elements literals-only, one level of nesting so an address allowlist of any width never counts as “deep”) or a bare field path, never an arbitrary expression, so args.x in [1 + 2] is rejected as a list-syntax error before type-checking ever runs.

Operators and functions

CategorySpellingOperand requirementResult
Boolean|| && !BoolBool
Equality== !=both sides’ scalar families intersect (IntInt, StrStr, bytes↔bytes, BoolBool)Bool
Ordering< <= > >=both sides IntBool (non-chaining)
Membershipinleft: a scalar; right: a literal list, or a path resolving to an array whose element family intersects the left’sBool
Stringstarts_with ends_with containsboth sides StrBool
Arithmetic+ - * / %both sides IntInt (/, % truncate toward zero; a literal-zero right side is a compile-time rejection)
Unary- (negation)IntInt

This table is exhaustive against crates/blockwatcher-expr/src/parser.rs’s BinOp enum (Or, And, Eq, Ne, Lt, Le, Gt, Ge, In, StartsWith, EndsWith, Contains, Add, Sub, Mul, Div, Mod) plus the unary node kinds, Not and Neg. There is no function call syntax, no user-defined name, and no loop construct anywhere in the grammar. starts_with/ends_with/contains/in are keywords lex_ident recognizes, not calls (lexer.rs), which is why the language has no general extensibility surface beyond what the parser hard-codes.

Literal forms

FormExampleNotes
Integer1_000_000arbitrary precision; _ separators anywhere in the digit run, may repeat or trail (1__0, 1_ both lex to 10 and 1)
Token-decimal1_000_000e6, 1.5e18exact integer expansion: mantissa (optionally with a fractional part) times 10^exponent; the fraction must fully resolve (1.5e0 is rejected: not an integer); exponent capped at 100
Hex bytes0xA0b8even, non-zero digit count; any length, not just 20-byte addresses
String"USDC"double-quoted; only \" and \\ are recognized escapes
Booleantrue / false
List[1, 2, 0xA0]literals only (optionally negated integers); appears only as in’s right-hand side

Field paths

A path is namespace.field, optionally followed by more .segments, parsed by parse_path (parser.rs). Two things resolve past the first dot, in order, inside SchemaSet::resolve (crates/blockwatcher-types/src/schema.rs):

  • A dotted flattened name, matched whole. A decoder that flattens a nested ABI parameter into order.maker is what makes args.order.maker resolve: the whole remainder after the namespace is one field name, checked as a unit, not traversed component by component.
  • A trailing run of digit segments, derived through a declared array type. args.tags.0 needs no declaration of its own; it resolves through tags’s declared Array(Str) to Str, one array layer unwrapped per digit segment. A digit segment too large to fit a usize is rejected: such an index could never be read back at evaluation time, so admitting it would compile a predicate that resolves to Unknown forever.

A flat declaration always wins over derivation when both would apply to the same spelling: a decoder’s explicit word for what it emits takes priority.

The type system: families, not exact types

The type-checker’s own vocabulary, the Family enum (crates/blockwatcher-expr/src/typecheck/mod.rs), is coarser than the canonical value model: Int and Uint collapse into one family, Int, because every arithmetic and ordering operator treats them identically; Address and Bytes collapse into one family too, because a predicate only ever compares them as raw bytes. A path can carry more than one family at once when heterogeneous selectors declare the same name at different types: a monitor with two selectors, one where args.id is a uint256 and another where it’s a bytes32, type-checks an operation against args.id if either declared type admits it.

Two consequences worth knowing:

  • A negative literal against an all-Uint field never gets as far as running. args.value == -1 against a field declared only uint256 fails to compile with 'args.value' is unsigned and can never satisfy this comparison (checked in crates/blockwatcher-expr/src/typecheck/mod.rs:182-204, the message itself built by diagnostics.rs:282-293). An unsigned value can never equal or be less than a negative number, so the predicate would never fire, and that’s caught before it’s ever stored rather than discovered later as a monitor that silently never matches. The opposite direction (args.value > -1, always true for a Uint) compiles: it’s loud in a different way, matching everything, which is visible, unlike a predicate that matches nothing.
  • A type error names the schema field it’s about whenever one side of the failure is a path, and otherwise names the computed families on both sides. args.name + 1 (a Str field used in arithmetic) reports 'args.name' is declared as [Str], which does not support arithmetic; 1 > "x" (neither side a field) reports ordering requires an integer, but a string was given.

Evaluation: three-valued, not two

Evaluation (crates/blockwatcher-expr/src/eval.rs) never errors and never panics. Every subexpression reduces to one of three truth values (True, False, or Unknown), and Unknown has exactly three sources: a field the occurrence doesn’t carry at all, an explicit null, or a runtime value that the operation in question cannot digest (possible only through a heterogeneous field, since the type-checker already confirmed some declared type admits the operation). matches() collapses Unknown the same way it collapses False (no match), which is what makes a predicate over a field only some of a monitor’s selectors produce safe to write: an event that never carries the field simply never satisfies that clause, on either side of a negation.

&& and || follow the standard three-valued (Kleene) tables rather than treating Unknown as either extreme:

aba && ba || b
TrueUnknownUnknownTrue
FalseUnknownFalseUnknown
UnknownUnknownUnknownUnknown

The practical effect: False && Unknown is False (nothing on the unresolved side could rescue a conjunct that already failed), but True || Unknown is True for the same reason in reverse. Neither of those outcomes requires evaluating the Unknown side at all, so evaluate short circuits exactly where the table already has an answer (eval.rs).

Integer comparisons are exact at every width the canonical value model carries: Value::Int/Value::Uint wrap num-bigint’s arbitrary-precision types, so a comparison against a number one above u128::MAX is not approximated: it is pinned directly in blockwatcher-expr’s own test suite (comparisons_are_exact_past_u128, eval.rs), and Int and Uint compare mathematically regardless of which one a decoder happened to spell a value as. Division and modulus truncate toward zero; a runtime zero divisor (reachable only through a heterogeneous field or an arithmetic expression whose value depends on the event) resolves to Unknown, never a panic.

Errors and the did-you-mean suggestion

Every PredicateError variant carries an operator-facing message, and two (UnknownField and UnknownNamespace) carry a suggestion when one exists. The mechanism is suggestion’s bounded edit-distance search (crates/blockwatcher-expr/src/typecheck/diagnostics.rs): every field the resolved namespace actually declares is compared against what was typed, a two-row dynamic-programming pass that bails out the moment a candidate provably can’t come in under the threshold, and the closest candidate within distance 3 is offered. Ties keep whichever declared name comes first. Given this page’s example schema, args.vlaue > 100 (a five-character transposition, edit distance 2 from args.value) is rejected at write time with unknown field 'args.vlaue' — did you mean 'args.value'? (the em-dash is part of the literal error text, verbatim from the code that emits it). Beyond distance 3 there is no suggestion at all: a wildly wrong name gets a plain rejection rather than a misleading nudge. A namespace typo (argss.value) gets the list of every namespace this monitor’s schema actually declares instead of a single guess, since there’s no declared spelling to measure distance against: the operator wrote a namespace, not a typo of a field.

This is a different mechanism from the one Resources describes for an unknown event or function name in a selector: that suggestion is simply the spec’s first declared name of the right kind, not an edit-distance match. The two live in different crates (blockwatcher-expr for predicates, blockwatcher-evm for selectors) because they check different vocabularies against different failure shapes.