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 — named
U256reservoirs 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 name 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": { "buckets": "execution_data_gas", "subtract_on_start_expr": "...", "delta_expr": "..." },
"message_receipt": { "buckets": ["execution_data_gas", "submitted_messages", "submitted_messages_count"], "subtract_on_start_expr": "...", "delta_expr": "..." },
"nondet_output": { "buckets": ["execution_data_gas", "nondet_outputs"], "subtract_on_start_expr": "...", "delta_expr": "..." },
"message_fee": { "buckets": "message_fee", "subtract_on_start_expr": "...", "delta_expr": "..." },
"event": { "buckets": "execution_data_gas", "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.bucketsEither a single name, or an array of names, in the bucket-total map the host passes to
DataLimit::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 name; in that case both charge against the same reservoir. Themessage_fee/message_receiptpair has special atomic-debit behaviour when they share a bucketsubtract_on_start_exprA numeric expression or array of numeric expressions. It follows the same scalar versus element-wise array rule as
delta_expr, including exact array-length matching. Evaluated once at startup withnodebound to the host-provided gas constants (see below). The resultingU256values are debited 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 five fee rules and their attrs shape are:
Name |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
The ground truth for these names is fees.rs (search for calculate_bucket
call sites). isFirstMessage is true until the first message emission is
successfully accumulated; event emissions do not clear it.
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.
node.overlaySplitBps carries the combined developer and DAO share of the
time-unit fee pool. Internal-message primary reserves use:
timeUnitPool
+ floor(timeUnitPool * overlaySplitBps / (10000 - overlaySplitBps))
+ executionTerm
This minimum primary fee is the same for messages emitted on acceptance and finalization. The execution term is not grossed up. Omitting the field makes internal-message fee evaluation fail rather than silently undercharge
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 five fee rules the evaluator builds
\node = <prelude> <code>forsubtract_on_start_expranddelta_expr, applies thenodeobject, and stores the resultingValue(a rational and a function respectively).For top-level runs, the startup cost is debited immediately. Underflow surfaces as the bucket’s configured OOM
VmError(see theVmError::oom().*enum chain at the call site). Nested runs receive zero-valued bucket placeholders and skip startup debits because their caller already paid them.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<HashMap<String,
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 name, 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": {
"buckets": "execution_data_gas",
"subtract_on_start_expr": "node.gasPerStorageBootstrap",
"delta_expr": "\\a = a.pages * node.gasPerStoragePage"
},
"message_receipt": {
"buckets": "message_fee",
"delta_expr":
"\\a = if a.isDeploy then node.gasPerDeployByte * (a.calldataLength + a.codeLength)\n else node.gasPerCallByte * a.calldataLength"
},
"nondet_output": {
"buckets": "nondet_outputs",
"delta_expr": "\\a = a.outputLength * node.gasPerNondetByte"
},
"message_fee": {
"buckets": "message_fee",
"delta_expr":
"\\a = if a.isInternal\n then node.gasInternal * arrayLen a.matchedFeeParams.rotations\n else a.matchedFeeParams.gasLimit * a.matchedFeeParams.maxGasPrice"
},
"event": {
"buckets": "execution_data_gas",
"delta_expr": "\\a = (a.topicsCount + a.blobSize) * node.gasPerStoragePage"
}
}
message_fee and message_receipt share the message_fee bucket here, so an
outbound message debits both costs atomically against it.
Message-Fee Allocation Matching#
The a.matchedFeeParams above is selected per outbound message from the call’s
allocation list (accumulator.message_fee_allocation). Kind is matched first
(External for EmitExternalMessage, else Internal), followed by exact
recipient and call_key. A per-recipient call_key wildcard is tried only
after the exact key; executor-only open-bucket recipient wildcards are less specific
than either. List order therefore cannot make a wildcard shadow an exact allocation.
For internal messages, on is checked after the allocation key is resolved, so a
phase mismatch on an exact allocation does not fall through to a wildcard. Chain
keys are unique across phases; only synthetic recipient-wildcard entries may
select different fee parameters by phase. For
external messages, an exact allocation without room for the reservation spills to
the per-recipient call_key wildcard. If neither key has a present allocation, the external message uses
the legacy unallocated path and consumes only its receipt cost. Existing but exhausted
candidates yield an allocation-budget error.
The default v0.3 fee expressions reject external reservations and receipts with
fee below_minimum unless node.lockedReceiptGasPrice is positive, including
external messages without a matching allocation
Chain entries, those with a concrete recipient, carry the budget stored on
chain. For internal messages, presence alone resolves a key: an entry whose
budget is zero still shadows broader keys and, when its phase matches, fails
emission with out_of message_fee allocation_budget internal, as does a key
exhausted by this execution’s own emissions, tracked separately; neither falls
through. For external messages, consensus resolves only chain keys with a nonzero
budget, so a zero-budget entry is absent: the message falls through to the
per-recipient call_key wildcard, and with no wildcard left takes the
unallocated path. Synthetic recipient wildcards match independently of budget;
zero there means an exhausted allocation. null removes the per-allocation cap
while keeping the execution’s fee buckets.
The host must preserve every existing pinned key and must not add recipient wildcards to a pinned tree. An empty list restricts internal pool-funded emissions. Open-pool and view executions may supply synthetic recipient wildcards with concrete fee parameters and phase, and optionally uncapped budgets. The executor conservatively treats each emission as novel; remaining allowances alone do not identify previously delivered occurrences.
Funding modes#
An outgoing internal message is funded one of two ways:
Allocation-matched (default). The fee is matched against the allocation list as above. After the expression computes the primary reserve, the executor adds the host-supplied
children_budget; deeper budgets are already contained by their direct parent. A declared budget exceeding the matched node’s remainingbudgetis rejected with an allocation-budget error; otherwise it consumesmessage_feeatomically with themessage_receiptcharge.Balance-funded (``use_balance``). When an
EmitInternalMessage/EmitInternalDeployMessagesetsuse_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 yieldsInsufficientBalance). 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.
For either phase, an internal message therefore declares:
minPrimaryFees(feeParams) + sum(directChildAllocation.budget)
For balance funding the sum is zero. The primary fee already covers the child’s
configured lifecycle, including appeals; the remainder becomes the child’s
message-fee bucket. An internal message whose declared amount is zero is rejected
with fee below_minimum on both funding paths. External messages declare zero
The v0.3 primary reserve includes successful-appellant profit for each configured
appeal slot. The bond is the next normal round’s time-unit cost, including its
rotations, multiplied by the child’s price cap. Its profit reserve is
bond + floor(bond / 2); the developer/DAO overlay applies only to time-unit
work, never to this profit reserve
The manager input message_fee_allocation contains only the allocations matched
by this execution. Each carries a required children_budget and opaque
subtree bytes. The host sums the direct descendants’ budgets and encodes
the matched subtree with any proof required by the transaction’s pinned
storage mode. Both executors forward those bytes unchanged; v0.3 charges their
full length against receipt gas, submitted-message bytes and memory. The subtree
includes the matched root; it is not merely an encoding of its descendants
children_budget funds onward messages per emission; it is
not the unspent allocation allowance or consumption from earlier generations.
All v0.3 internal emissions reject zero time-unit, storage or receipt price caps
with Inval before charging fees or appending an emission
The parent allocation’s aggregate capacity is a separate invariant. If
L = appealRounds + 1 novel executions may each emit a child carrying budget
C, preserving that descendant capacity for every execution requires:
allocationBudget >= L * (minPrimaryFees + C)
The lifecycle multiplier therefore belongs to this parent allocation-capacity check, not to one emitted child’s declared budget
The current minimum L * minPrimaryFees + C reserves C only once. Whether
an allocation promises descendant capacity once or once per accepted execution
remains unspecified; this does not change the per-message formula
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 inU256without saturating.The floor replicates the chain’s
minMessagePrimaryFees: the consensus term is charged at the guest’smax_price_gen_per_time_unitfunding cap, then the developer and DAO overlay is grossed up on that time-unit pool. 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. Unless both allocations are zero, the leader and validator time units must also fit the host-providedminProposeTimeout/maxProposeTimeoutandminCommitTimeout/maxCommitTimeoutranges; violations producefee phase_timeout_out_of_boundson both funding paths.