Fees, Buckets and Expressions#
GenVM charges for run-time resources through a small, operator-defined expression
language. The configuration is consumed by executor/src/rt/fees.rs and the
expression evaluator lives in executor/crates/common/src/expr/.
Three independent moving parts are involved:
Buckets — opaque integer reservoirs (
U256) that GenVM debits during execution. Their initial totals come from the host (typically the consensus layer) on a per-call basis.Bucket configs — operator-provided rules that bind a named charge (storage pages, message receipts, nondet output bytes, message fees) to a bucket number and an expression that computes the per-event cost.
Expressions — a small typed lambda calculus used to write the cost rules. It is evaluated once at startup (for
subtract_on_start_expr) and once per charge (fordelta_expr).
Configuration#
The fees block of the GenVM config (see doc/schemas/default-config.json,
genvm-fees-conf) has the following shape:
"fees": {
"expr_prelude": "<expression>",
"storage": { "bucket_no": N, "subtract_on_start_expr": "...", "delta_expr": "..." },
"message_receipt": { "bucket_no": N, "subtract_on_start_expr": "...", "delta_expr": "..." },
"nondet_output": { "bucket_no": N, "subtract_on_start_expr": "...", "delta_expr": "..." },
"message_fee": { "bucket_no": N, "subtract_on_start_expr": "...", "delta_expr": "..." }
}
expr_preludeShared expression text prepended to every bucket expression before parsing. The intended use is shared
letbindings (or Y-combinator definitions) that the per-bucket expressions can reference.bucket_noEither a single index, or an array of indices, into the
Vec<U256>of bucket totals the host passes toDataLimit::new. With an array, a scalardelta_exprresult is charged against every listed bucket, while an array result is charged element-wise (lengths must match); all debits in one charge are atomic (all-or-nothing). Two bucket configs MAY share the same index; in that case both charge against the same reservoir. Themessage_fee/message_receiptpair has special atomic-debit behaviour when they share a bucket (consume_message_feeinfees.rs:332).subtract_on_start_exprA bare numeric expression. Evaluated once at startup with
nodebound to the host-provided gas constants (see below). The resultingU256is debited from the bucket immediately. Defaults to"0"if omitted.delta_exprA function
\attrs = body(one or more\lambdas) whoseattrsparameter is an object containing the variables that the charge depends on. Evaluated once per charge; the resulting integer is debited from the bucket.
The four named buckets and their attrs shape are:
Name |
|
|---|---|
|
|
|
|
|
|
|
|
The ground truth for these names is fees.rs (search for calculate_bucket
call sites).
The Expression Language#
Source files: executor/crates/common/src/expr/.
The language is a pure, call-by-need lambda calculus over rationals, booleans,
strings, arrays and (sorted) objects. It is deliberately small and runs without
fuel or recursion limits — the comment in evaluator.rs:8 reads:
SAFETY: this evaluator has no recursion depth or fuel limits. It is only used for trusted, operator-supplied fee config expressions, never for contract-supplied or user-supplied input.
Operators MUST NOT expose this surface to contract or end-user input.
Syntax#
expr ::= let NAME = expr in expr
| if expr then expr else expr
| \NAME [NAME...] = expr -- lambda; multi-param is sugar
| comparison
comparison ::= sum (('<' | '>' | '<=' | '>=' | '==' | '!=') sum)?
sum ::= product (('+' | '-') product)*
product ::= unary (('*' | '/') unary)*
unary ::= '-' unary | application
application ::= primary (primary)*
primary ::= NUMBER | NAME | STRING | array | object | '(' expr ')' | primary '.' NAME
array ::= '[' (expr (',' expr)*)? ']'
object ::= '{' (NAME '=' expr ';')* '}'
STRING ::= '"' ( char | '\(' expr ')' )* '"' -- supports interpolation
let is non-recursive: the body of let f = ... in ... cannot reference
f. Recursion is expressed with a Y combinator, e.g.:
let Y = \f = (\x = f (x x)) (\x = f (x x)) in
let fact = Y (\rec n = if n <= 1 then 1 else n * rec (n - 1)) in
fact 5
Strings support \(expr) interpolation; toString and \(..) use the same
rendering.
Types#
number— arbitrary-precision rational (BigRational). Integer division of two rationals yields a rational; usefloorbefore converting back toU256if you need truncation.boolstring(Arc<str>)array(Arc<Vec<Value>>)object(sortedBTreeMap<String, Value>, accessed via.orhasKey)function(host or guest, both call-by-need)
Type errors are reported eagerly; division by zero is a runtime error.
Builtins#
The following names are predefined by the evaluator (resolve_builtin in
evaluator.rs):
floor n— floor of a rational.toString v— render any value as a string.hasKey obj k— whether the object has the given key.arrayLen a,arrayGetElem a i— array length and indexing.pow base exp— integer-exponent power (negative exponents take the reciprocal;pow 0 (-n)is a division-by-zero error).
Free Variables and node#
A free variable not satisfied by the surrounding let bindings or the builtin
table is looked up via the host-provided get_var. In the fee evaluator the only
free variable allowed at the top level is node: an object built from the
gas_data map handed to DataLimit::new. Each (name, raw) pair in
gas_data is itself evaluated as an expression (against expr_prelude) and the
result is inserted into node under name.
Bucket expressions reference these as e.g. node.gasPerChangedSlot and the
node-provided values let consensus parameters drift independently of the executor
binary.
The closing of node happens at startup:
\node = <prelude> <code> is parsed and applied to the resolved node object.
The returned value (a number for subtract_on_start_expr, a function for
delta_expr) closes over node and has no remaining free variables; any
later lookup at charge time is a configuration bug and is reported as
UndefinedVariable.
Evaluation Model#
Lazy. Argument thunks are forced on first use and memoised (
Thunk::forceinvalue.rs). A self-referential force returns"infinite recursion while forcing a lazy value"rather than deadlocking.Pure. No I/O, no mutation, no clock access. The only nondeterminism the evaluator can produce is the result of host functions, which in the fee surface is a no-op (
no_free_varsis the resolver).
Charge Lifecycle#
Per process, per non-batched call:
The host invokes
DataLimit::new(bucket_totals, fees_cfg, gas_data).The evaluator parses
expr_prelude + gas_data[i].valuefor everygas_dataentry and builds thenodeobject. Parse or evaluation errors here surface asparsing gas_data constant 'X'/evaluating gas_data constant 'X'.For each of the four buckets the evaluator builds
\node = <prelude> <code>forsubtract_on_start_expranddelta_expr, applies thenodeobject, and stores the resultingValue(a rational and a function respectively).The startup cost is debited immediately. Underflow surfaces as the bucket’s configured OOM
VmError(see theVmError::oom().*enum chain at the call site).During the run, every
consume_*function infees.rspacks the per-eventattrsinto an object, applies the bucket’sdeltafunction, converts the result toU256(rejecting non-integers and negatives), and debits the reservoir.
Bucket totals are mutated under a single tokio::sync::Mutex<Vec<U256>> so the
debits are sequentially consistent across concurrent sub-VMs. message_fee and
message_receipt debits are atomic with respect to each other: when they share a
bucket_no, the costs are summed and debited once; otherwise both reservoirs
are checked before either is decremented. This avoids partial charges when a
combined send-and-deliver flow runs out of fee budget mid-call.
Failure Modes#
Parse error — bad prelude/expr syntax. Surfaced as
parsing <label> fee expression; the GenVM process refuses to start.Evaluation error at startup — type mismatch, undefined variable, division by zero. Same surface; refuses to start.
Type / range error during a charge — the delta returned a non-integer, a negative, or a value above
U256::MAX. Logged asfailed to evaluate fee expressionand propagated to the caller; the cost is treated as undefined and the charge fails closed.Bucket underflow at startup —
subtract_on_startexceeds the bucket total. Returns the bucket’s OOMVmErrorimmediately.Bucket underflow during a charge —
consume_bucket_rawreturnsfalse. The caller turns this into an OOM-class error (oom().storage()forconsume_storage_pages,oom().receipt().nondet_output()forconsume_nondet_output, etc.).
Configuration Sketch#
A minimal, illustrative fees block:
"fees": {
"expr_prelude":
"let Y = \\f = (\\x = f (x x)) (\\x = f (x x)) in",
"storage": {
"bucket_no": 0,
"subtract_on_start_expr": "node.gasPerStorageBootstrap",
"delta_expr": "\\a = a.pages * node.gasPerStoragePage"
},
"message_receipt": {
"bucket_no": 1,
"delta_expr":
"\\a = if a.isDeploy then node.gasPerDeployByte * (a.calldataLength + a.codeLength)\n else node.gasPerCallByte * a.calldataLength"
},
"nondet_output": {
"bucket_no": 2,
"delta_expr": "\\a = a.outputLength * node.gasPerNondetByte"
},
"message_fee": {
"bucket_no": 1,
"delta_expr":
"\\a = if a.isInternal\n then (if a.onAcceptance then node.gasInternalAccept else node.gasInternalFinal)\n else floor (a.matchedFeeParams.executionBudgetPerRound * arrayLen a.matchedFeeParams.rotations)"
}
}
message_fee and message_receipt share bucket_no = 1 here, so an outbound
message debits both costs atomically against bucket 1.
Message-Fee Allocation Matching#
The a.matchedFeeParams above is selected per outbound message from the call’s
allocation list (accumulator.message_fee_allocation). A node
(domain/fees.rs, matches_internal / matches_external) matches on kind
(External for EthSend, else Internal), on (internal only), and
recipient / call_key (each a wildcard None or an exact match). Selection
is first-match-wins in list order (find_map); no match yields the
fee no_matching_node VM error.
Wildcards are not reordered, so a wildcard node shadows every more-specific node after it: producers are advised to sort more-specific nodes ahead of wildcard ones.
Funding modes#
An outgoing internal message is funded one of two ways:
Allocation-matched (default). The fee is matched against the allocation tree as above, capped by the matched node’s
budget, and consumes both themessage_feeandmessage_receiptbuckets.Balance-funded (``use_balance``). When a
PostMessage/DeployContractsetsuse_balance(the chain’suseBalance, gated on can_use_balance_for_message_fees), allocation matching is skipped entirely. The fee is metered from the guest-suppliedfee_paramsand that metered amount is the child’sdeclaredBudget, reserved from the emitting contract’s balance (jointly withvalue; insufficient balance yieldsInbalance). Themessage_feebucket is not consumed (the message is excluded from the sender pool on-chain); onlymessage_receiptis. The emitted allocation subtree is empty, so nested child messages must each fund themselves.Because
fee_paramsis guest-supplied, it is validated before it reaches the fee evaluator (validate_balance_fee): emptyrotationsand any zero price cap (max_price_gen_per_time_unit/storage_fee_max_gas_price/receipt_fee_max_gas_price) are rejected withInval, matching the chain’s reveal-timeFeeValueMustBeNonZerochecks. Magnitudes are bounded too (prices/budgets below 296, counts — time units and rotations entries — below 232) so the worst-case metered floor provably fits inU256and cannot trip the evaluator’s overflow abort.The floor replicates the chain’s
minMessagePrimaryFeesrather than the allocation-matched floor: the consensus term is charged at the guest’smax_price_gen_per_time_unit(the funding cap), not the node’sgenPerTimeUnit(the allocation path keeps thegenPerTimeUnitmultiplier — this branch is selected by thebalanceFundedflag threaded into themessage_feeexpression). Two further node-configured floors fire asVMErrors:fee below_minimumwhen a non-zeroexecution_budget_per_roundis belownode.messageBudgetFloor(the chain’sBudgetTooLow), andfee too_many_roundswhenrotationsimplies more rounds than the validator table (node.validatorsPerRound) covers. The per-phase time-unit floor (fee below_minimumagainstnode.minTimeUnitsPerPhase) applies on both funding paths.