Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Prose references a version as `v0.X.Y`; headings stay bare `[0.X.Y]`.

### Added

- Real gas budget enforcement on the WASM engine (`runtime/src/lib.rs`). The v0.3 runtime tracked `gas_used` per dimension but never aborted on overflow; that caveat is gone. Two layers of metering now compose:
- **Per-dimension gas budgets**: `Instance::set_gas_budget(dim, units)` sets a budget; `env.gas_consume(dim, amount)` traps the current call when `gas_used + amount > budget`. Dimensions without a budget set are unmetered. Lets chains express the multi-dimensional gas model from RFC #7 (cpu / storage / witness / etc.).
- **Wasmtime fuel**: enabled in `Config::consume_fuel(true)` so every WASM instruction consumes fuel. Catches infinite loops in pure WASM that never call into a hostcall. `Instance::set_fuel(units)` sets the budget; `Instance::fuel_remaining()` reports how much is left. Default is 10 billion units (`DEFAULT_FUEL`), generous enough that existing callers see no behavior change.
- New `gas_budgets: HashMap<u32, u64>` on `HostState` and the new `Instance` setters / readers (`set_gas_budget`, `set_fuel`, `fuel_remaining`).
- 7 new unit tests: unmetered call, under-budget call, over-budget traps with a clear "out of gas" message, orthogonal-dimension budget ignored, default fuel sufficient for 100 increment calls, fuel actually decreases across a call, zero-fuel traps. Plus a hand-crafted `GAS_TEST_WASM` constant (73 bytes) that exercises `env.gas_consume` since the v0.3 codegen does not yet emit gas_consume calls.
- Closes #51.
- Codegen for `match` statements (`compiler/src/codegen.c`). The v0.3 codegen rejected `match`; this lifts the restriction. v0 lowering: save scrutinee to a shared scratch local, then chain `i64.eq` comparisons inside nested `if 0x40 ... else ... end`. Supported patterns: integer literal, bool literal, identifier (treated as wildcard for v0 since binding patterns need locals + sum types). Wildcard arms terminate the chain as the innermost else branch. Closes #43.
- Match scratch local. The fn-body pre-scan now detects any `match` statement and allocates one shared scratch local for the scrutinee value. Matches are sequential within a fn (the next match runs only after the previous arm body completes), so one scratch slot covers all matches in a fn.
- `count_local_slots` helper replaces `count_let_bindings`. Returns both let-binding count and a match-presence flag, which `emit_fn_body` translates into a single locals declaration group.
Expand Down
2 changes: 1 addition & 1 deletion runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ These numbers reflect raw VM dispatch only. Real chain throughput will be bound
## What this runtime does not yet do

- Persistence across processes (state is in-memory only)
- Real gas budgets (`gas_consume` records usage but never aborts; EVM gas is metered by REVM but no budget enforcement at the cross-engine level)
- Cross-engine gas accounting (the WASM engine enforces both Wasmtime fuel and per-dimension gas budgets; the EVM engine has its own REVM-internal gas, but no unified budget across the two)
- Event payloads serialized from module memory beyond raw byte copy
- Multiple modules sharing state
- Cross-module calls
Expand Down
220 changes: 214 additions & 6 deletions runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,13 @@ pub struct HostState {
/// these; the field is here so the runtime can record them once
/// codegen does.
pub events: Vec<EventRecord>,
/// Per-dimension gas consumed (dimension index -> units). Total is
/// uncapped in v0; an out-of-gas trap will land with the next
/// runtime iteration.
/// Per-dimension gas consumed (dimension index -> units).
pub gas_used: HashMap<u32, u64>,
/// Per-dimension gas budget (dimension index -> max units). Dimensions
/// without an entry are unmetered. When `gas_used + amount` would
/// exceed `gas_budgets[dim]`, the `gas_consume` hostcall returns an
/// error and Wasmtime traps the current call.
pub gas_budgets: HashMap<u32, u64>,
}

/// A single recorded event. Cleave-compiled v0.3 modules never emit
Expand Down Expand Up @@ -80,18 +83,35 @@ impl Runtime {
// bigger surgery on the engine config than v0 warrants.
config.wasm_simd(false);
config.wasm_relaxed_simd(false);
// Enable fuel metering so we can bound total execution per
// call. Fuel ticks at the instruction level which catches
// infinite loops in pure WASM (no hostcall in the loop body
// would otherwise let a malicious module starve the node).
config.consume_fuel(true);
Self {
engine: Engine::new(&config).expect("wasmtime config is always valid"),
}
}

/// Load a WASM module from raw bytes, link the env-namespace
/// hostcalls per `spec/abi/wasm.md`, and instantiate.
///
/// The new instance starts with `DEFAULT_FUEL` units of Wasmtime
/// fuel and no per-dimension gas budget set. Callers tune both
/// via [`Instance::set_fuel`] and [`Instance::set_gas_budget`]
/// before the first call.
pub fn load(&self, wasm: &[u8]) -> Result<Instance> {
let module = Module::new(&self.engine, wasm)
.context("loading wasm bytes into a Module")?;

let mut store = Store::new(&self.engine, HostState::default());
// Generous default so existing callers that do not opt into
// metering still see fully-executed modules. Tests and chain
// integrations should set their own budget via
// `Instance::set_fuel` immediately after `load`.
store
.set_fuel(DEFAULT_FUEL)
.expect("fuel is enabled on the engine config");

let mut linker: Linker<HostState> = Linker::new(&self.engine);
link_hostcalls(&mut linker)?;
Expand All @@ -104,6 +124,11 @@ impl Runtime {
}
}

/// Default fuel budget assigned to every freshly loaded instance.
/// Generous enough for normal calls; a node operating a real chain
/// will replace this with a per-transaction budget derived from gas.
pub const DEFAULT_FUEL: u64 = 10_000_000_000;

impl Default for Runtime {
fn default() -> Self {
Self::new()
Expand Down Expand Up @@ -159,6 +184,32 @@ impl Instance {
.copied()
.unwrap_or(0)
}

/// Set the per-dimension gas budget. When `gas_used + amount` for
/// a dimension would exceed its budget, `env.gas_consume` traps
/// the current call. Dimensions without a budget set are
/// unmetered.
pub fn set_gas_budget(&mut self, dimension: u32, budget: u64) {
self.store.data_mut().gas_budgets.insert(dimension, budget);
}

/// Replace the current Wasmtime fuel budget. Lower values bound
/// total per-call execution more tightly; ~10 units per WASM
/// instruction is a reasonable rule of thumb. Callers should set
/// this before the first `call` on an instance; setting it after
/// a partially-consumed call is supported but does not refund
/// already-spent fuel.
pub fn set_fuel(&mut self, units: u64) -> Result<()> {
self.store
.set_fuel(units)
.map_err(|e| anyhow!("set_fuel: {e}"))
}

/// Read the remaining Wasmtime fuel. Useful as a coarse "how
/// much execution did the last call consume" measurement.
pub fn fuel_remaining(&self) -> u64 {
self.store.get_fuel().unwrap_or(0)
}
}

/// Register the env-namespace hostcalls onto a Wasmtime linker. The
Expand Down Expand Up @@ -191,11 +242,27 @@ fn link_hostcalls(linker: &mut Linker<HostState>) -> Result<()> {
.func_wrap(
"env",
"gas_consume",
|mut caller: Caller<'_, HostState>, dimension: i32, amount: i64| {
|mut caller: Caller<'_, HostState>, dimension: i32, amount: i64| -> Result<()> {
let dim = dimension as u32;
let amount = amount.max(0) as u64;
let used = caller.data_mut().gas_used.entry(dim).or_insert(0);
*used = used.saturating_add(amount);
let state = caller.data_mut();
let prev = state.gas_used.get(&dim).copied().unwrap_or(0);
let new_total = prev.saturating_add(amount);
if let Some(&budget) = state.gas_budgets.get(&dim) {
if new_total > budget {
// Returning Err here causes Wasmtime to trap
// the current call. The error propagates out
// through `Instance::call` as an Err result,
// which the chain layer can surface to the
// transaction submitter as an "out of gas".
return Err(anyhow!(
"out of gas: dimension {dim} budget {budget} \
exceeded ({new_total} > {budget})"
));
}
}
state.gas_used.insert(dim, new_total);
Ok(())
},
)
.context("linking env.gas_consume")?;
Expand Down Expand Up @@ -323,4 +390,145 @@ mod tests {
let msg = format!("{err:#}");
assert!(msg.contains("does_not_exist"), "error should name missing fn: {msg}");
}

// ====== gas-budget tests (issue #51) ======

/// Hand-crafted WASM module: imports env.gas_consume, exports
/// `do_work` which calls `gas_consume(0, 100)` then returns 1.
/// Verifying budget enforcement requires actually executing a
/// module that calls into the hostcall; the standard counter
/// module never does.
///
/// Disassembled:
///
/// (module
/// (type (func (param i32 i64)))
/// (type (func (result i64)))
/// (import "env" "gas_consume" (func (type 0)))
/// (func (export "do_work") (result i64)
/// i32.const 0 ;; dimension 0
/// i64.const 100 ;; charge 100 units
/// call 0 ;; gas_consume
/// i64.const 1 ;; return 1
/// ))
#[rustfmt::skip]
const GAS_TEST_WASM: &[u8] = &[
// magic + version
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
// type section: 2 types
0x01, 0x0A, 0x02,
0x60, 0x02, 0x7F, 0x7E, 0x00, // (i32, i64) -> ()
0x60, 0x00, 0x01, 0x7E, // () -> i64
// import section: env.gas_consume : type 0
0x02, 0x13, 0x01,
0x03, b'e', b'n', b'v',
0x0B, b'g', b'a', b's', b'_', b'c', b'o', b'n', b's', b'u', b'm', b'e',
0x00, 0x00,
// function section: 1 fn of type 1
0x03, 0x02, 0x01, 0x01,
// export section: "do_work" func 1 (after import 0)
0x07, 0x0B, 0x01,
0x07, b'd', b'o', b'_', b'w', b'o', b'r', b'k',
0x00, 0x01,
// code section: 1 fn body, 11 bytes
0x0A, 0x0D, 0x01, 0x0B,
0x00, // 0 locals
0x41, 0x00, // i32.const 0
0x42, 0xE4, 0x00, // i64.const 100 (signed LEB)
0x10, 0x00, // call 0 (gas_consume)
0x42, 0x01, // i64.const 1
0x0B, // end
];

#[test]
fn gas_consume_without_budget_runs_freely() {
let rt = Runtime::new();
let mut instance = rt.load(GAS_TEST_WASM).expect("gas test wasm loads");
let result = instance.call("do_work", &[]).expect("unmetered call succeeds");
assert_eq!(result, 1);
assert_eq!(instance.gas_used(0), 100);
}

#[test]
fn gas_consume_under_budget_runs_and_records_usage() {
let rt = Runtime::new();
let mut instance = rt.load(GAS_TEST_WASM).expect("gas test wasm loads");
instance.set_gas_budget(0, 1_000);
let result = instance.call("do_work", &[]).expect("under-budget call succeeds");
assert_eq!(result, 1);
assert_eq!(instance.gas_used(0), 100);
}

#[test]
fn gas_consume_over_budget_traps_with_clear_error() {
let rt = Runtime::new();
let mut instance = rt.load(GAS_TEST_WASM).expect("gas test wasm loads");
instance.set_gas_budget(0, 50);
let err = instance.call("do_work", &[]).expect_err("should trap");
let msg = format!("{err:#}");
assert!(
msg.contains("out of gas"),
"error should report out of gas: {msg}"
);
// gas_used stays at the pre-call value since the consume was
// rejected before the accumulator updated.
assert_eq!(instance.gas_used(0), 0);
}

#[test]
fn gas_budget_only_applies_to_set_dimension() {
let rt = Runtime::new();
let mut instance = rt.load(GAS_TEST_WASM).expect("gas test wasm loads");
// Tighten dimension 1; the wasm charges dimension 0 only, so
// the call should still succeed.
instance.set_gas_budget(1, 1);
let result = instance.call("do_work", &[]).expect("orthogonal dim budget");
assert_eq!(result, 1);
assert_eq!(instance.gas_used(0), 100);
assert_eq!(instance.gas_used(1), 0);
}

#[test]
fn fuel_default_is_sufficient_for_counter_module() {
// Regression guard on DEFAULT_FUEL: existing counter calls
// must not exhaust the default fuel allocation.
let rt = Runtime::new();
let mut instance = rt.load(COUNTER_WASM).expect("counter loads");
for _ in 0..100 {
instance.call("increment", &[]).expect("under default fuel");
}
assert_eq!(instance.state(0), 100);
}

#[test]
fn fuel_is_actually_consumed() {
// First confirm fuel metering is wired up at all. One
// `increment` call should consume a non-trivial amount of
// fuel from the default budget.
let rt = Runtime::new();
let mut instance = rt.load(COUNTER_WASM).expect("counter loads");
let before = instance.fuel_remaining();
instance.call("increment", &[]).unwrap();
let after = instance.fuel_remaining();
assert!(
after < before,
"fuel must decrease across a call (before={before}, after={after})"
);
}

#[test]
fn fuel_exhaustion_traps_the_call() {
let rt = Runtime::new();
let mut instance = rt.load(COUNTER_WASM).expect("counter loads");
// Zero fuel means even a single instruction traps. Wasmtime's
// exact fuel cost per WASM instruction varies by backend and
// version; zero is the one budget guaranteed to trap.
instance.set_fuel(0).unwrap();
let err = instance.call("increment", &[]).expect_err("should trap on fuel");
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("fuel") || msg.to_lowercase().contains("trap"),
"error should mention fuel exhaustion: {msg}"
);
}
}
Loading