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 treatsUnknownthe same asFalse. - An unknown field or namespace gets a bounded edit-distance suggestion within distance 3; beyond that there is no suggestion at all.
expris 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):
||&&!(prefix; its operand is itself parsed at this level, so!chains, and it wraps an entire comparison rather than binding inside one:!a.x == 1parses as!(a.x == 1), not(!a.x) == 1)- Comparisons:
==!=<<=>>=instarts_withends_withcontains(non-chaining, exactly as shown above) +-*/%- Unary
- - 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
| Category | Spelling | Operand requirement | Result |
|---|---|---|---|
| Boolean | || && ! | Bool | Bool |
| Equality | == != | both sides’ scalar families intersect (Int↔Int, Str↔Str, bytes↔bytes, Bool↔Bool) | Bool |
| Ordering | < <= > >= | both sides Int | Bool (non-chaining) |
| Membership | in | left: a scalar; right: a literal list, or a path resolving to an array whose element family intersects the left’s | Bool |
| String | starts_with ends_with contains | both sides Str | Bool |
| Arithmetic | + - * / % | both sides Int | Int (/, % truncate toward zero; a literal-zero right side is a compile-time rejection) |
| Unary | - (negation) | Int | Int |
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
| Form | Example | Notes |
|---|---|---|
| Integer | 1_000_000 | arbitrary precision; _ separators anywhere in the digit run, may repeat or trail (1__0, 1_ both lex to 10 and 1) |
| Token-decimal | 1_000_000e6, 1.5e18 | exact 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 bytes | 0xA0b8 | even, non-zero digit count; any length, not just 20-byte addresses |
| String | "USDC" | double-quoted; only \" and \\ are recognized escapes |
| Boolean | true / 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.makeris what makesargs.order.makerresolve: 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.0needs no declaration of its own; it resolves throughtags’s declaredArray(Str)toStr, one array layer unwrapped per digit segment. A digit segment too large to fit ausizeis rejected: such an index could never be read back at evaluation time, so admitting it would compile a predicate that resolves toUnknownforever.
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-
Uintfield never gets as far as running.args.value == -1against a field declared onlyuint256fails to compile with'args.value' is unsigned and can never satisfy this comparison(checked incrates/blockwatcher-expr/src/typecheck/mod.rs:182-204, the message itself built bydiagnostics.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 aUint) 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(aStrfield used in arithmetic) reports'args.name' is declared as [Str], which does not support arithmetic;1 > "x"(neither side a field) reportsordering 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:
a | b | a && b | a || b |
|---|---|---|---|
True | Unknown | Unknown | True |
False | Unknown | False | Unknown |
Unknown | Unknown | Unknown | Unknown |
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.