Lines
89.8 %
Functions
26.17 %
Branches
100 %
//! Shared wasmtime primitives used by every host of nomiscript-compiled
//! modules: the entity-script `ScriptExecutor` and the rpc eval channel.
//!
//! Owns the engine config (WasmGC + epoch interruption + optional fuel),
//! the per-bytecode `Module` cache, the trap-classification helper, and the
//! `decode_eval_result` helper that walks the nomi-eval `(ref null any)`
//! return value into a structured [`EvalValue`]. Higher-level consumers
//! parameterize over the Store data type and assemble their own Linker on top.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use thiserror::Error;
use uuid::Uuid;
use wasmtime::{
AnyRef, AsContextMut, Caller, Config, Engine, FieldType, Linker, Module, Mutability, Rooted,
StorageType, Store, StructRef, StructRefPre, StructType, Val, ValType,
};
#[derive(Debug, Error)]
pub enum EngineError {
#[error("engine config rejected: {0}")]
Config(String),
#[error("module cache lock poisoned")]
CachePoisoned,
#[error("module compilation failed: {0}")]
Compile(String),
#[error("module instantiation failed: {0}")]
Instantiate(String),
#[error("fuel configuration failed: {0}")]
Fuel(String),
#[error("missing export `{0}`")]
MissingExport(String),
#[error("fuel exhausted before completion")]
OutOfFuel,
#[error("epoch deadline reached before completion")]
EpochInterrupt,
/// `ConvertCommodity` raises this when no Price row links source
/// and target in either direction. Lifted into a dedicated variant
/// so clients can prompt the user to add a price row rather than
/// guess from a generic trap message.
#[error("no conversion: {0}")]
NoConversion(String),
/// A structured error raised in-guest, surfaced via the `__nomi_raise`
/// host fn (`Err(wasmtime::Error::msg("__nomi_raise:CODE:MSG"))`). Two
/// sources converge here (ADR-0026): a script `(error 'code "msg")`, and
/// an engine error like a commodity mismatch — both `throw $nomi_error`
/// in-guest, and the boundary wrapper around each host-invoked body
/// catches an uncaught throw and bridges it to `__nomi_raise`. The
/// classifier parses the marker prefix and surfaces the code symbol
/// (`COMMODITY-MISMATCH`, a script's own symbol, …) onto the wire
/// envelope's `:code` slot. Codes are reader-folded (upper-cased)
/// symbols, not free-form strings.
#[error("script raised {code}: {message}")]
ScriptRaised { code: String, message: String },
#[error("execution trapped: {0}")]
Trap(String),
}
/// Marker prefix the `__nomi_raise` host fn embeds in its wasmtime
/// error message so the runtime classifier can recognise script-raised
/// errors before the unreachable-trap branch fires. Kept here so the
/// host-fn body and the classifier agree on the wire format.
pub const NOMI_RAISE_MARKER: &str = "__nomi_raise:";
/// Optional profiling strategy. JitDump is the Linux `perf record`
/// flow; it writes a `jit-<pid>.dump` file the OS-level profiler can
/// read. PerfMap is the simpler symbol-name-only Linux variant. Both
/// require the `wasmtime/profiling` cargo feature, which we pull in
/// via the `jitdump` feature flag on the scripting crate; non-Linux
/// builds should leave this as `None`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProfilerStrategy {
#[default]
None,
JitDump,
PerfMap,
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EngineOpts {
pub fuel: bool,
pub profiler: ProfilerStrategy,
impl EngineOpts {
#[must_use]
pub const fn baseline() -> Self {
Self {
fuel: false,
profiler: ProfilerStrategy::None,
pub const fn with_fuel(mut self) -> Self {
self.fuel = true;
self
pub const fn with_profiler(mut self, strategy: ProfilerStrategy) -> Self {
self.profiler = strategy;
impl Default for EngineOpts {
fn default() -> Self {
Self::baseline()
pub fn build_engine(opts: EngineOpts) -> Result<Engine, EngineError> {
let mut config = Config::new();
config.wasm_gc(true);
config.wasm_function_references(true);
// Exception-handling proposal: `(error)` lowers to `throw $nomi_error`
// and `(handler-case)` / `(unwind-protect)` lower to `try_table`
// (Tier 3, ADR-0026). Engine traps (`OutOfFuel` / `EpochInterrupt`)
// are not wasm exceptions, so they bypass `try_table` and keep the
// per-Session deadline budget non-catchable.
config.wasm_exceptions(true);
config.epoch_interruption(true);
if opts.fuel {
config.consume_fuel(true);
match opts.profiler {
ProfilerStrategy::None => {}
ProfilerStrategy::JitDump => {
config.profiler(wasmtime::ProfilingStrategy::JitDump);
ProfilerStrategy::PerfMap => {
config.profiler(wasmtime::ProfilingStrategy::PerfMap);
Engine::new(&config).map_err(|e| EngineError::Config(e.to_string()))
pub fn compile_module(engine: &Engine, bytes: &[u8]) -> Result<Module, EngineError> {
Module::new(engine, bytes).map_err(|e| EngineError::Compile(e.to_string()))
pub fn compile_wat(engine: &Engine, source: &str) -> Result<Module, EngineError> {
Module::new(engine, source).map_err(|e| EngineError::Compile(e.to_string()))
/// Per-engine bytecode cache keyed by the full module bytes. Cloning a
/// [`ModuleCache`] yields a handle into the same inner map; meant to be
/// shared between long-lived hosts (Session, ScriptExecutor) and any per-form
/// helpers that need the same cached compilation.
#[derive(Debug, Default, Clone)]
pub struct ModuleCache {
inner: Arc<Mutex<HashMap<Vec<u8>, Module>>>,
impl ModuleCache {
pub fn new() -> Self {
Self::default()
pub fn get_or_compile(&self, engine: &Engine, bytecode: &[u8]) -> Result<Module, EngineError> {
if let Some(module) = self.lookup(bytecode)? {
return Ok(module);
let module = compile_module(engine, bytecode)?;
self.store(bytecode, module.clone())?;
Ok(module)
fn lookup(&self, bytecode: &[u8]) -> Result<Option<Module>, EngineError> {
let guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
Ok(guard.get(bytecode).cloned())
fn store(&self, bytecode: &[u8], module: Module) -> Result<(), EngineError> {
let mut guard = self.inner.lock().map_err(|_| EngineError::CachePoisoned)?;
guard.insert(bytecode.to_vec(), module);
Ok(())
pub fn is_empty(&self) -> Result<bool, EngineError> {
Ok(guard.is_empty())
pub fn len(&self) -> Result<usize, EngineError> {
Ok(guard.len())
/// Classifies a [`wasmtime::Error`] thrown during execution into a typed
/// [`EngineError`]. Downcast to [`wasmtime::Trap`] handles the structured
/// fuel/epoch cases; everything else falls through as `Trap(message)`.
pub fn classify_runtime_error(err: &wasmtime::Error) -> EngineError {
if let Some(trap) = err.downcast_ref::<wasmtime::Trap>() {
match *trap {
wasmtime::Trap::OutOfFuel => return EngineError::OutOfFuel,
wasmtime::Trap::Interrupt => return EngineError::EpochInterrupt,
_ => {}
// Walk the error chain so host-fn `wasmtime::Error::msg("...")` causes
// (e.g. "get-commodity: invalid uuid '...'") surface alongside the
// wasmtime wrapper's "error while executing at wasm backtrace" header.
// `err.to_string()` alone only renders the outermost wrapper, which
// hides the diagnostic the host fn actually emitted.
let mut combined = err.to_string();
for cause in err.chain().skip(1) {
combined.push_str(": ");
combined.push_str(&cause.to_string());
// Script-raised errors must classify *before* the unreachable-trap
// branch: `(error 'code "msg")` lowers to `__nomi_raise` returning
// `Err(wasmtime::Error::msg("__nomi_raise:CODE:MSG"))`. Returning
// `Err` from a host fn never trips `unreachable`, so ADR-0014's
// single-unreachable invariant is preserved — but the chain walk
// above stitches the host-fn message into `combined`, and the
// marker prefix lets us recover the symbol/message without parsing
// wasmtime's wrapper text.
if let Some(raised) = parse_nomi_raise_marker(err) {
return raised;
if combined.contains("convert-commodity: no Price row")
|| combined.contains("convert-commodity: inverse price has zero numerator")
{
return EngineError::NoConversion(combined);
EngineError::Trap(combined)
/// Walks the error chain for a `__nomi_raise:CODE:MSG` marker emitted by
/// the `__nomi_raise` host fn. Returns the structured `ScriptRaised`
/// variant when found, otherwise `None`. Searches the chain rather than
/// the combined string so script-raised codes survive intact even when
/// MSG itself contains literal `:` characters.
fn parse_nomi_raise_marker(err: &wasmtime::Error) -> Option<EngineError> {
err.chain()
.map(|cause| cause.to_string())
.find_map(|cause_str| split_marker(&cause_str))
fn split_marker(text: &str) -> Option<EngineError> {
let rest = text.strip_prefix(NOMI_RAISE_MARKER)?;
let (code, message) = rest.split_once(':')?;
Some(EngineError::ScriptRaised {
code: code.to_string(),
message: message.to_string(),
})
/// Maps an `EngineError` to the `(code, message)` pair scripts and
/// batch consumers see when a script fails — code is a kebab-case
/// symbol (matching the wire envelope's `:code` slot for catch-each
/// cells and `server::script` per-tx reports), message is the
/// engine's own diagnostic string.
///
/// Engine-bound deadlines (`OutOfFuel`, `EpochInterrupt`) also have a
/// mapping here so callers that *do* want to surface them to scripts
/// (batch runners that don't care about catch-each's "engine deadlines
/// aren't catchable" rule) can. catch-each filters the deadlines out
/// before reaching this mapper.
pub fn err_code_and_message(err: &EngineError) -> (String, String) {
match err {
EngineError::ScriptRaised { code, message } => (code.clone(), message.clone()),
EngineError::NoConversion(msg) => ("no-conversion".to_string(), msg.clone()),
EngineError::Trap(msg) => ("runtime".to_string(), msg.clone()),
EngineError::Compile(msg) => ("compile".to_string(), msg.clone()),
EngineError::Instantiate(msg) => ("runtime".to_string(), msg.clone()),
EngineError::Fuel(msg) => ("runtime".to_string(), msg.clone()),
EngineError::MissingExport(msg) => ("runtime".to_string(), msg.clone()),
EngineError::Config(msg) => ("runtime".to_string(), msg.clone()),
EngineError::CachePoisoned => (
"runtime".to_string(),
"module cache lock poisoned".to_string(),
),
EngineError::OutOfFuel => ("runtime".to_string(), "fuel exhausted".to_string()),
EngineError::EpochInterrupt => ("runtime".to_string(), "epoch deadline".to_string()),
/// Allocates an ATOMIC `$commodity` value by re-entering the guest's exported
/// `commodity_new` with the four i64 components (numer, denom, commodity_hi,
/// commodity_lo). Since ADR-0028 E0 the `$commodity` struct carries a 5th
/// `(ref null $unit_term)` field; the host must NOT construct that ref-bearing
/// struct itself, so it delegates to the guest helper (which sets the term to
/// null = atomic) — the same re-entry pattern as `alloc_pair_chain` / the
/// entity allocators. Async because it calls back into the wasm instance.
pub async fn alloc_commodity_ref<T>(
caller: &mut Caller<'_, T>,
numer: i64,
denom: i64,
commodity_id: Uuid,
) -> wasmtime::Result<Rooted<StructRef>>
where
T: Send,
let commodity_new = caller
.get_export("commodity_new")
.and_then(|e| e.into_func())
.ok_or_else(|| {
wasmtime::Error::msg(
"module missing 'commodity_new' export — host commodity allocation \
requires the nomiscript compiler skeleton's exported commodity_new",
)
})?;
let (hi, lo) = commodity_id.as_u64_pair();
let mut results = [Val::AnyRef(None)];
commodity_new
.call_async(
caller.as_context_mut(),
&[
Val::I64(numer),
Val::I64(denom),
Val::I64(hi as i64),
Val::I64(lo as i64),
],
&mut results,
.await?;
match &results[0] {
Val::AnyRef(Some(any)) => any.unwrap_struct(caller.as_context_mut()),
Val::AnyRef(None) => Err(wasmtime::Error::msg("commodity_new returned null")),
_ => Err(wasmtime::Error::msg(
"commodity_new returned non-anyref Val variant",
)),
/// Allocates a `$i8_array` wasm array holding `bytes` and returns a rooted
/// reference. Single allocation; callers can format UUID/name payloads into
/// a reused `Vec<u8>` and ship the bytes without an intermediate `String`.
/// Engine canonicalizes the i8 array type so the host-side allocation
/// matches the guest's `(array i8)` declaration in
/// `CompileContext::new_skeleton`.
pub fn alloc_string_ref<T>(
bytes: &[u8],
) -> wasmtime::Result<Rooted<wasmtime::ArrayRef>> {
let engine = caller.engine().clone();
// Mutability must match the compiler's `register_type("i8_array")` field
// declaration (mutable: true) — engine canonicalization compares the
// mutability bit, so a `Const` array type would fail the guest's
// `ref.cast (ref $i8_array)` even though the storage type matches.
let ty = wasmtime::ArrayType::new(&engine, FieldType::new(Mutability::Var, StorageType::I8));
let pre = wasmtime::ArrayRefPre::new(caller.as_context_mut(), ty);
let vals: Vec<Val> = bytes.iter().map(|b| Val::I32(i32::from(*b))).collect();
wasmtime::ArrayRef::new_fixed(caller.as_context_mut(), &pre, &vals)
/// Allocates a `$ratio` wasm struct (2 i64 fields: numer, denom). Mirrors
/// `alloc_commodity_ref` for the Ratio numeric stratum — used when a host
/// fn returns a typed Ratio without going through the synthesized
/// `ratio_new` wrap.
pub fn alloc_ratio_ref<T>(
) -> wasmtime::Result<Rooted<StructRef>> {
let ty = StructType::new(
&engine,
std::iter::repeat_n(
FieldType::new(Mutability::Const, StorageType::ValType(ValType::I64)),
2,
)?;
let pre = StructRefPre::new(caller.as_context_mut(), ty);
StructRef::new(
&pre,
&[Val::I64(numer), Val::I64(denom)],
/// Allocates an entity wasm struct (`$account`, `$commodity_entity`, etc) by
/// re-entering the module's exported `alloc_<kind>` function. The host can't
/// freshly construct an entity `StructType` via `StructType::new` — fields
/// like `(ref null $i8_array)` reference concrete type indices that engine
/// canonicalization compares by identity, so any abstract `anyref`-typed
/// fresh declaration produces a structurally distinct (and uncastable) type.
/// Re-entry through the guest's own allocator (registered in
/// `CompileContext::register_entity_allocators`) sidesteps the issue: each
/// call returns a struct ref of the exact `$<kind>` type the subsequent
/// `ref.cast (ref $<kind>)` in the consuming form accepts.
/// Args are passed in declaration order matching the entity's struct
/// field layout (see `CompileContext::new_skeleton`).
pub async fn alloc_entity_via_export<T>(
export_name: &str,
args: &[Val],
let alloc = caller
.get_export(export_name)
wasmtime::Error::msg(format!(
"module missing '{export_name}' export — host entity allocation requires \
the nomiscript compiler skeleton's exported alloc_<kind> function"
))
alloc
.call_async(caller.as_context_mut(), args, &mut results)
let new_entity_any = match &results[0] {
Val::AnyRef(any) => *any,
_ => {
return Err(wasmtime::Error::msg(format!(
"{export_name} returned non-anyref Val variant"
)));
new_entity_any
"{export_name} returned null when allocating entity"
})?
.unwrap_struct(caller.as_context_mut())
/// Reads a `$i8_array` arg ref into a Rust `String`. `None` is returned for
/// null refs (the wasm-level `(ref null $i8_array)` param's null state). The
/// underlying byte storage is i8 (mutable per `register_type("i8_array")`),
/// so each element is read via `array.get_u` semantics and assembled into a
/// `Vec<u8>` then UTF-8 validated. Non-UTF-8 bytes surface as a structured
/// trap rather than a silent replacement.
pub fn read_string_arg<T>(
arg: Option<Rooted<wasmtime::ArrayRef>>,
) -> wasmtime::Result<Option<String>> {
let Some(arr) = arg else {
return Ok(None);
let len = arr.len(caller.as_context_mut())?;
let mut bytes = Vec::with_capacity(len as usize);
for i in 0..len {
let val = arr.get(caller.as_context_mut(), i)?;
let byte_i32 = val
.i32()
.ok_or_else(|| wasmtime::Error::msg("string arg element is not i32"))?;
bytes.push(byte_i32 as u8);
String::from_utf8(bytes)
.map(Some)
.map_err(|err| wasmtime::Error::msg(format!("string arg is not valid UTF-8: {err}")))
/// Reads a `$commodity` arg ref into its (numer, denom, commodity_id)
/// components. Mirrors `read_string_arg` for the Commodity numeric stratum:
/// fields 0-1 are numer/denom, fields 2-3 are the UUID halves. `None`
/// returns for a null ref; bad shape surfaces as a structured trap.
pub fn read_commodity_arg<T>(
arg: Option<Rooted<StructRef>>,
) -> wasmtime::Result<Option<(i64, i64, Uuid)>> {
let Some(s) = arg else {
let read_i64 = |c: &mut Caller<'_, T>, idx: usize| -> wasmtime::Result<i64> {
let v = s.field(c.as_context_mut(), idx)?;
v.i64()
.ok_or_else(|| wasmtime::Error::msg(format!("commodity field {idx} is not i64")))
let numer = read_i64(caller, 0)?;
let denom = read_i64(caller, 1)?;
let hi = read_i64(caller, 2)?;
let lo = read_i64(caller, 3)?;
let raw = ((hi as u64 as u128) << 64) | (lo as u64 as u128);
Ok(Some((numer, denom, Uuid::from_u128(raw))))
/// Reads a `$ratio` arg ref into its `(numer, denom)` components. `None`
/// returns for a null ref; a zero denominator is rejected as a structured trap
/// so callers never divide by zero. Mirrors `read_commodity_arg` for the
/// dimensionless Scalar stratum (a `draft-split` amount).
pub fn read_ratio_arg<T>(
) -> wasmtime::Result<Option<(i64, i64)>> {
let numer = s
.field(caller.as_context_mut(), 0)?
.i64()
.ok_or_else(|| wasmtime::Error::msg("ratio field 0 (numer) is not i64"))?;
let denom = s
.field(caller.as_context_mut(), 1)?
.ok_or_else(|| wasmtime::Error::msg("ratio field 1 (denom) is not i64"))?;
if denom == 0 {
return Err(wasmtime::Error::msg("ratio has zero denominator"));
Ok(Some((numer, denom)))
/// Reads a named String field from an entity struct arg, resolving the field's
/// slot index from [`nomiscript::entity_layout`] (the single source of struct
/// slot order). `None` returns for a null ref. Errors if the kind has no
/// layout, the named field is absent or non-String, or the slot is not an
/// `$i8_array`. This is how the draft natives read e.g. an account's `id`
/// (slot 0) from a `(get-account …)` entity ref passed as an argument.
pub fn read_entity_string_field<T>(
kind: nomiscript::EntityKind,
field_name: &str,
read_entity_string_field_ctx(caller.as_context_mut(), s, kind, field_name).map(Some)
/// Context-based core of [`read_entity_string_field`], split out so it is
/// unit-testable without a `Caller` (tests hold a bare `Store`). Resolves the
/// field's slot from the entity layout and reads it as an `$i8_array` string.
pub fn read_entity_string_field_ctx(
mut store: impl AsContextMut,
entity: Rooted<StructRef>,
) -> wasmtime::Result<String> {
let layout = nomiscript::entity_layout(kind)
.ok_or_else(|| wasmtime::Error::msg(format!("no entity layout for {kind:?}")))?;
let idx = layout
.fields
.iter()
.position(|f| f.name == field_name && f.kind == nomiscript::EntityFieldKind::String)
"entity {kind:?} has no String field named '{field_name}'"
let arr = match entity.field(store.as_context_mut(), idx)? {
Val::AnyRef(Some(any)) => any.unwrap_array(store.as_context_mut())?,
"entity {kind:?} field '{field_name}' (slot {idx}) is not an i8_array"
let len = arr.len(store.as_context_mut())?;
let byte = arr
.get(store.as_context_mut(), i)?
.ok_or_else(|| wasmtime::Error::msg("entity string element is not i32"))?;
bytes.push(byte as u8);
.map_err(|err| wasmtime::Error::msg(format!("entity string field not utf-8: {err}")))
/// Folds an iterator of GC-ref elements into a `$pair` chain by re-entering
/// the wasm module via its exported `pair_new` function. Returns the chain
/// head, or `None` if the iterator is empty.
/// `$pair` is the self-recursive cell type (`{anyref car, ref null $pair
/// cdr}`) declared by `CompileContext::new_skeleton`. The host can't freshly
/// construct that StructType via `StructType::new` — the cdr field references
/// the type itself, which `StructType::new` doesn't model. Instead this
/// helper reaches into the module's own type system: `pair_new` is already
/// emitted by the compiler skeleton as `register_function("pair_new", ...)`
/// which adds it to the export table, so the host fn body can pull it from
/// `Caller::get_export` and invoke it per element. Each call produces a
/// `Rooted<StructRef>` of the exact `$pair` type that subsequent `ref.cast
/// (ref null $pair)` operations in the guest accept.
/// Items are folded right-to-left so the first element of the iterator
/// ends up at the chain head — `[a, b, c]` → `(a . (b . (c . nil)))`.
pub async fn alloc_pair_chain<T>(
items: impl IntoIterator<Item = Rooted<AnyRef>>,
) -> wasmtime::Result<Option<Rooted<StructRef>>>
let pair_new = caller
.get_export("pair_new")
"module missing 'pair_new' export — host pair allocation requires \
the nomiscript compiler skeleton's exported pair_new",
let items: Vec<Rooted<AnyRef>> = items.into_iter().collect();
let mut head: Option<Rooted<StructRef>> = None;
for item in items.into_iter().rev() {
let cdr_any = head.map(|p| p.to_anyref());
pair_new
&[Val::AnyRef(Some(item)), Val::AnyRef(cdr_any)],
let new_pair_any = match &results[0] {
return Err(wasmtime::Error::msg(
"pair_new returned non-anyref Val variant",
));
head = Some(
new_pair_any
wasmtime::Error::msg("pair_new returned null when chaining elements")
.unwrap_struct(caller.as_context_mut())?,
);
Ok(head)
/// Instantiates `module` against an empty linker and calls a zero-arg export
/// returning a single `i64`. Constraints (fuel cap, epoch deadline) come from
/// the caller-supplied `Store`.
pub fn call_i64_export<T>(
engine: &Engine,
store: &mut Store<T>,
module: &Module,
export: &str,
) -> Result<i64, EngineError> {
let linker = Linker::<T>::new(engine);
let instance = linker
.instantiate(&mut *store, module)
.map_err(|e| classify_runtime_error(&e))?;
let func = instance
.get_typed_func::<(), i64>(&mut *store, export)
.map_err(|_| EngineError::MissingExport(export.to_string()))?;
func.call(&mut *store, ())
.map_err(|e| classify_runtime_error(&e))
/// Final value captured by an eval-mode module via the `nomi_capture_*` host
/// fns. Mirrors the subset of [`nomiscript::WasmType`] variants the compiler
/// emits as terminal stack types, plus a `Bytes` variant for native fns that
/// marshal compound data (server-command results via `scripting-format`,
/// chart SVGs, exported files, etc.). Cons/Vector/Closure/Struct still wait
/// for the GC migration.
#[derive(Debug, Clone, PartialEq)]
pub enum EvalValue {
Nil,
Bool(bool),
I32(i32),
Ratio {
},
/// Commodity-bearing amount: rational + originating commodity uuid.
/// Distinct from `Ratio` so cross-strata arithmetic is rejected by
/// the compiler before any wire round-trip. Wire form via
/// `format_value`: `(:commodity <ratio> :id "<uuid>")`.
Commodity {
commodity_hi: i64,
commodity_lo: i64,
String(String),
Bytes(Vec<u8>),
impl From<EvalValue> for nomiscript::Value {
fn from(value: EvalValue) -> Self {
match value {
EvalValue::Nil => nomiscript::Value::Nil,
EvalValue::Bool(b) => nomiscript::Value::Bool(b),
EvalValue::I32(n) => {
nomiscript::Value::Number(nomiscript::Fraction::from_integer(i64::from(n)))
EvalValue::Ratio { numer, denom } => {
nomiscript::Value::Number(nomiscript::Fraction::new(numer, denom))
EvalValue::Commodity {
numer,
denom,
commodity_hi,
commodity_lo,
} => {
// Reassemble the 16-byte uuid from the wasm-side (hi, lo)
// i64 pair. Both halves get cast to u64 first so negative
// i64 patterns don't sign-extend into bogus high bits.
let raw = ((commodity_hi as u64 as u128) << 64) | (commodity_lo as u64 as u128);
nomiscript::Value::Commodity {
amount: nomiscript::Fraction::new(numer, denom),
commodity_id: uuid::Uuid::from_u128(raw),
EvalValue::String(s) => nomiscript::Value::String(s),
EvalValue::Bytes(b) => nomiscript::Value::Bytes(b),
/// Decodes nomi-eval's anyref return value into an [`EvalValue`] using
/// the compile-time-known result type. `None` for the result_ty means
/// the form was empty / definition-only and the host should see
/// [`EvalValue::Nil`]. Numeric types (`I32`, `Ratio`, `Commodity`) and
/// `StringRef` decode directly. `PairRef` walks the chain, decoding
/// each car per its declared element type. `EntityRef` returns a
/// placeholder until a downstream consumer needs the structured
/// shape host-side. Takes any `AsContextMut` so it works with both
/// `&mut Store<T>` (sync test paths) and `&mut Caller<'_, T>` (async
/// host fn paths).
pub fn decode_eval_result(
value: Option<Rooted<AnyRef>>,
result_ty: Option<nomiscript::WasmType>,
) -> wasmtime::Result<EvalValue> {
let Some(ty) = result_ty else {
return Ok(EvalValue::Nil);
// Reference-typed results can legitimately be null: empty
// `pair<…>` from `list-accounts`, `Option<Rooted<…>>` returns
// surfacing not-found / missing-string cases. Surface those as
// `Nil` so consumers see an empty-shaped value rather than a
// trap. Primitive (I32) returns can't be null and stay strict.
let Some(any) = value else {
return match ty {
nomiscript::WasmType::I32 => Err(wasmtime::Error::msg(
"nomi-eval returned null for declared result type i32",
nomiscript::WasmType::PairRef(_) => Ok(EvalValue::String("()".into())),
_ => Ok(EvalValue::Nil),
decode_anyref(&mut store, any, ty)
fn decode_anyref(
any: Rooted<AnyRef>,
ty: nomiscript::WasmType,
use nomiscript::WasmType;
match ty {
WasmType::I32 => {
let i31 = any
.unwrap_i31(&mut store)
.map_err(|err| wasmtime::Error::msg(format!("expected i31, got {err}")))?;
Ok(EvalValue::I32(i31.get_i32()))
WasmType::Bool => {
// A boolean result is i31-boxed like an i32, but decodes to the
// falsy-nil / truthy-bool pair nomiscript uses (matches the
// const-fold path): 0 → Nil, nonzero → Bool(true).
if i31.get_i32() == 0 {
Ok(EvalValue::Nil)
} else {
Ok(EvalValue::Bool(true))
WasmType::Ratio => {
let s = any.unwrap_struct(&mut store)?;
.field(&mut store, 0)?
.field(&mut store, 1)?
Ok(EvalValue::Ratio { numer, denom })
WasmType::Commodity => {
let numer = s.field(&mut store, 0)?.i64().unwrap_or(0);
let denom = s.field(&mut store, 1)?.i64().unwrap_or(1);
// Field 4 is the unit term (ADR-0028). Null ⇒ ATOMIC single-currency
// money (id in fields 2-3). An empty term ⇒ DIMENSIONLESS (money ÷
// money, same currency) and decodes as a plain Number. A non-empty
// (compound) term — e.g. money×money — has no host wire form yet.
match s.field(&mut store, 4)? {
Val::AnyRef(None) => {
let hi = s.field(&mut store, 2)?.i64().unwrap_or(0);
let lo = s.field(&mut store, 3)?.i64().unwrap_or(0);
Ok(EvalValue::Commodity {
commodity_hi: hi,
commodity_lo: lo,
Val::AnyRef(Some(term)) => {
let arr = term.unwrap_array(&mut store)?;
if arr.len(&mut store)? == 0 {
Err(wasmtime::Error::msg(
"compound commodity (e.g. money × money) has no host \
representation yet",
"commodity field 4 (unit term) is not a ref",
WasmType::StringRef => {
let arr = any.unwrap_array(&mut store)?;
let len = arr.len(&mut store)?;
let v = arr.get(&mut store, i)?;
let byte = v
.ok_or_else(|| wasmtime::Error::msg("string element is not i32"))?;
let s = String::from_utf8(bytes)
.map_err(|err| wasmtime::Error::msg(format!("not valid utf-8: {err}")))?;
Ok(EvalValue::String(s))
WasmType::PairRef(elem) => {
let head = render_pair_as_string(&mut store, any, elem)?;
Ok(EvalValue::String(head))
WasmType::EntityRef(kind) => {
let entity = any.unwrap_struct(&mut store)?;
Ok(EvalValue::String(render_entity(&mut store, entity, kind)?))
WasmType::Closure(_) => {
let _ = any;
Ok(EvalValue::String("<closure>".into()))
WasmType::AnyRef => {
// Heterogeneous payload (catch-each result cells, etc.).
// The host renderer cannot statically pick a decoder, so it
// surfaces a placeholder; the script-side accessor natives
// (`ok?` / `err-code` / etc.) are responsible for inspecting
// the contents.
Ok(EvalValue::String("<anyref>".into()))
/// Walks a `$pair` chain and renders it as a Lisp-style list-of-cars
/// textual form `( <car> <car> ... )`. Element decoding dispatches on
/// the compile-time PairElement so each car gets its proper formatter.
fn render_pair_as_string(
head_any: Rooted<AnyRef>,
elem: nomiscript::PairElement,
let mut out = String::from("(");
let mut cur: Option<Rooted<StructRef>> = Some(head_any.unwrap_struct(&mut store)?);
let mut first = true;
while let Some(node) = cur {
if !first {
out.push(' ');
first = false;
let car_val = node.field(&mut store, 0)?;
let car_any = match car_val {
Val::AnyRef(Some(a)) => a,
out.push_str("nil");
let cdr_val = node.field(&mut store, 1)?;
cur = match cdr_val {
Val::AnyRef(Some(a)) => Some(a.unwrap_struct(&mut store)?),
_ => None,
continue;
return Err(wasmtime::Error::msg("pair car is not anyref"));
let car_str = render_car(&mut store, car_any, elem)?;
out.push_str(&car_str);
out.push(')');
Ok(out)
fn render_car(
car_any: Rooted<AnyRef>,
use nomiscript::PairElement;
match elem {
PairElement::I32 => {
let i31 = car_any.unwrap_i31(&mut store)?;
Ok(i31.get_i32().to_string())
PairElement::Bool => {
// Shares the i31 car with I32, but renders as a truth value: 0 →
// `nil`, nonzero → `t` (matching the bool/nil wire convention).
Ok(if i31.get_i32() == 0 { "nil" } else { "t" }.to_string())
PairElement::Ratio => {
let s = car_any.unwrap_struct(&mut store)?;
let n = s.field(&mut store, 0)?.i64().unwrap_or(0);
let d = s.field(&mut store, 1)?.i64().unwrap_or(1);
if d == 1 {
Ok(n.to_string())
Ok(format!("{n}/{d}"))
PairElement::Commodity => {
// Field 4 (unit term, ADR-0028) decides the wire form — SAME rules
// as the top-level `decode_anyref` Commodity arm, so a compound
// money riding a `$pair` cell can't slip through as id-zero atomic
// money: null ⇒ atomic (id in fields 2-3), empty ⇒ dimensionless
// Number, non-empty (compound) ⇒ error (no host wire form yet).
let id = Uuid::from_u128(raw);
Ok(format!("(:commodity {n} :id \"{id}\")"))
Ok(format!("(:commodity {n}/{d} :id \"{id}\")"))
Ok(if d == 1 {
n.to_string()
format!("{n}/{d}")
PairElement::StringRef => {
let arr = car_any.unwrap_array(&mut store)?;
bytes.push(v.i32().unwrap_or(0) as u8);
let s = String::from_utf8(bytes).unwrap_or_else(|_| "<invalid-utf8>".into());
Ok(format!("\"{s}\""))
PairElement::Entity(kind) => {
let entity = car_any.unwrap_struct(&mut store)?;
render_entity(&mut store, entity, kind)
PairElement::AnyRef => Ok("<anyref>".into()),
/// Renders a typed entity struct as a readable plist —
/// `(:commodity :id "…" :symbol "USD" :name "US Dollar")` — by reading each
/// field at its `struct.get` slot per the single-source-of-truth
/// [`nomiscript::entity_layout`] (generated from `entity_registry.org`). Field
/// slot order in the layout matches the wasm struct exactly. A `Pair` field
/// (the recursive `report_node.children`) renders as `()`-elided rather than
/// walking the tree, since the host renderer has no element-type context there.
fn render_entity(
use nomiscript::EntityFieldKind;
let Some(layout) = nomiscript::entity_layout(kind) else {
// No field layout (e.g. Condition isn't a server entity).
return Ok(format!("(:{kind:?})"));
let mut out = format!("(:{}", layout.label);
for (slot, field) in layout.fields.iter().enumerate() {
let rendered = match field.kind {
EntityFieldKind::String => read_string_slot(&mut store, entity, slot)?,
EntityFieldKind::Ratio => read_ratio_slot(&mut store, entity, slot)?,
EntityFieldKind::I32 => entity
.field(&mut store, slot)?
.unwrap_or(0)
.to_string(),
// Recursive child list (report_node.children): no element-type
// context here, so elide rather than mis-decode.
EntityFieldKind::Pair => "(...)".to_string(),
out.push_str(&format!(" :{} {rendered}", field.name));
/// Reads a `(ref null $i8_array)` string field at `slot`, rendered as a quoted
/// literal. A null/empty slot renders as `""`.
fn read_string_slot(
slot: usize,
match entity.field(&mut store, slot)? {
Val::AnyRef(Some(a)) => {
let arr = a.unwrap_array(&mut store)?;
bytes.push(arr.get(&mut store, i)?.i32().unwrap_or(0) as u8);
_ => Ok("\"\"".to_string()),
/// Reads a `(ref null $ratio)` field at `slot` (i64 numer/denom in slots 0/1 of
/// the ratio struct), rendered as `n` or `n/d`. A null slot renders as `0`.
fn read_ratio_slot(
let s = a.unwrap_struct(&mut store)?;
_ => Ok("0".to_string()),
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn err_code_uses_script_raised_symbol_verbatim() {
let (code, msg) = err_code_and_message(&EngineError::ScriptRaised {
code: "no-such-account".to_string(),
message: "id=42".to_string(),
});
assert_eq!(code, "no-such-account");
assert_eq!(msg, "id=42");
fn err_code_maps_commodity_mismatch_script_raise_to_symbol() {
// Commodity mismatch now `throw`s `$nomi_error` in-guest (ADR-0026);
// uncaught, the boundary wrapper bridges it to `__nomi_raise` and the
// classifier yields `ScriptRaised`. The code is the reader-folded
// (upper-cased) symbol `COMMODITY-MISMATCH`, like any script raise —
// `err_code_and_message` passes a `ScriptRaised` code through verbatim.
code: "COMMODITY-MISMATCH".to_string(),
message: "USD vs EUR".to_string(),
assert_eq!(code, "COMMODITY-MISMATCH");
assert_eq!(msg, "USD vs EUR");
fn err_code_maps_no_conversion_to_kebab_symbol() {
let (code, msg) =
err_code_and_message(&EngineError::NoConversion("missing price".to_string()));
assert_eq!(code, "no-conversion");
assert_eq!(msg, "missing price");
fn err_code_falls_back_to_runtime_for_generic_traps() {
let (code, msg) = err_code_and_message(&EngineError::Trap("oops".to_string()));
assert_eq!(code, "runtime");
assert_eq!(msg, "oops");
fn err_code_maps_out_of_fuel_to_runtime_with_diagnostic_message() {
let (code, msg) = err_code_and_message(&EngineError::OutOfFuel);
assert_eq!(msg, "fuel exhausted");
fn store_with_fuel<T: Default>(engine: &Engine, fuel: u64) -> Store<T> {
let mut store = Store::new(engine, T::default());
store
.set_fuel(fuel)
.expect("set_fuel must succeed for fresh store");
store.set_epoch_deadline(1);
fn baseline_engine_omits_fuel() {
let opts = EngineOpts::baseline();
assert!(!opts.fuel);
let _engine = build_engine(opts).expect("baseline engine must build");
fn with_fuel_engine_supports_set_fuel() {
let engine = build_engine(EngineOpts::baseline().with_fuel()).unwrap();
let mut store: Store<()> = Store::new(&engine, ());
.set_fuel(1_000)
.expect("set_fuel works only when consume_fuel is on");
fn module_cache_returns_same_module_for_same_bytecode() {
let engine = build_engine(EngineOpts::baseline()).unwrap();
let cache = ModuleCache::new();
let wat = r#"(module (func (export "answer") (result i64) (i64.const 42)))"#;
let bytes = wat::parse_str(wat).unwrap();
assert_eq!(cache.len().unwrap(), 0);
let _first = cache.get_or_compile(&engine, &bytes).unwrap();
assert_eq!(cache.len().unwrap(), 1);
let _second = cache.get_or_compile(&engine, &bytes).unwrap();
fn module_cache_clones_share_storage() {
let cache_a = ModuleCache::new();
let cache_b = cache_a.clone();
let _ = cache_a.get_or_compile(&engine, &bytes).unwrap();
assert_eq!(cache_b.len().unwrap(), 1);
fn runs_trivial_i64_export() {
let module = compile_wat(
r#"(module (func (export "answer") (result i64) (i64.const 42)))"#,
.unwrap();
let mut store: Store<()> = store_with_fuel(&engine, 100_000);
let result = call_i64_export(&engine, &mut store, &module, "answer").unwrap();
assert_eq!(result, 42);
fn missing_export_returns_typed_error() {
let err = call_i64_export(&engine, &mut store, &module, "missing").unwrap_err();
assert!(matches!(err, EngineError::MissingExport(name) if name == "missing"));
fn fuel_exhaustion_yields_typed_error() {
r#"
(module
(func (export "spin") (result i64)
(loop (br 0))
(i64.const 0)))
"#,
let mut store: Store<()> = store_with_fuel(&engine, 1_000);
let err = call_i64_export(&engine, &mut store, &module, "spin").unwrap_err();
assert!(matches!(err, EngineError::OutOfFuel), "got: {err:?}");
fn epoch_interrupt_yields_typed_error() {
store.set_fuel(1_000_000_000).unwrap();
engine.increment_epoch();
assert!(
matches!(err, EngineError::EpochInterrupt | EngineError::OutOfFuel),
"got: {err:?}"
fn malformed_module_bytes_yield_compile_error() {
let err = compile_module(&engine, b"not wasm bytes").unwrap_err();
assert!(matches!(err, EngineError::Compile(_)));
#[tokio::test(flavor = "current_thread")]
async fn alloc_pair_chain_builds_list_head_in_order() {
use wasmtime::I31;
// Self-recursive $pair shape matching `CompileContext::new_skeleton`.
// The module exports `pair_new` (the helper alloc_pair_chain re-enters
// for each element) and a `go` entry that asks the test host fn for a
// 3-element chain, then walks it to confirm the host-built structure.
let wat = r#"
(rec
(type $pair (struct (field anyref) (field (ref null $pair)))))
(import "test" "make_chain"
(func $make_chain (result (ref null struct))))
(func $pair_new (export "pair_new")
(param $car anyref) (param $cdr (ref null $pair))
(result (ref null $pair))
(struct.new $pair (local.get $car) (local.get $cdr)))
(func $length (param $head (ref null $pair)) (result i32)
(local $count i32)
(block $exit
(loop $more
(br_if $exit (ref.is_null (local.get $head)))
(local.set $count (i32.add (local.get $count) (i32.const 1)))
(local.set $head
(struct.get $pair 1 (local.get $head)))
(br $more)))
(local.get $count))
(func (export "go") (result i32)
(local $head (ref null $pair))
(ref.cast (ref null $pair) (call $make_chain)))
(call $length (local.get $head))))
"#;
let module = compile_wat(&engine, wat).unwrap();
let mut linker: Linker<()> = Linker::new(&engine);
linker
.func_wrap_async("test", "make_chain", |mut caller: Caller<'_, ()>, ()| {
Box::new(async move {
let items: Vec<Rooted<AnyRef>> = (0..3)
.map(|i| AnyRef::from_i31(caller.as_context_mut(), I31::wrapping_u32(i)))
.collect();
alloc_pair_chain(&mut caller, items).await
store.set_epoch_deadline(1_000);
let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
let go = instance.get_func(&mut store, "go").unwrap();
let mut results = [Val::I32(0)];
go.call_async(&mut store, &[], &mut results).await.unwrap();
assert_eq!(results[0].i32(), Some(3));
async fn alloc_pair_chain_errors_without_pair_new_export() {
use wasmtime::Func;
// No `pair_new` export — the host fn must surface the missing-export
// contract violation rather than panic or silently succeed.
(import "test" "try_chain"
(func $try))
(func (export "go") (call $try)))
.func_wrap_async("test", "try_chain", |mut caller: Caller<'_, ()>, ()| {
let empty: Vec<Rooted<AnyRef>> = Vec::new();
let result = alloc_pair_chain(&mut caller, empty).await;
match result {
Err(e) => {
let msg = e.to_string();
msg.contains("pair_new"),
"expected pair_new-missing error, got: {msg}"
Ok(_) => Err(wasmtime::Error::msg(
"alloc_pair_chain unexpectedly succeeded without pair_new",
let go: Func = instance.get_func(&mut store, "go").unwrap();
let mut results: [Val; 0] = [];
async fn alloc_commodity_ref_builds_atomic_via_reentry() {
// ADR-0028 E0: the host builds a commodity by re-entering the module's
// exported `commodity_new`, which writes a NULL unit-term (= atomic).
// The `$commodity` shape matches `CompileContext::new_skeleton` (5
// fields, the 5th a `(ref null $unit_term)`). `go` asks the host for a
// 7/2 commodity with UUID hi=1/lo=2, then reads numer, hi, lo, and
// whether the term is null.
(type $unit_term (array (mut i64)))
(type $commodity
(struct (field i64) (field i64) (field i64) (field i64)
(field (ref null $unit_term))))
(import "test" "make_commodity"
(func $make_commodity (result (ref null struct))))
(func $commodity_new (export "commodity_new")
(param $n i64) (param $d i64) (param $hi i64) (param $lo i64)
(result (ref $commodity))
(struct.new $commodity
(local.get $n) (local.get $d) (local.get $hi) (local.get $lo)
(ref.null $unit_term)))
(func (export "go") (result i64 i64 i64 i32)
(local $c (ref $commodity))
(local.set $c
(ref.cast (ref $commodity) (call $make_commodity)))
(struct.get $commodity 0 (local.get $c))
(struct.get $commodity 2 (local.get $c))
(struct.get $commodity 3 (local.get $c))
(ref.is_null (struct.get $commodity 4 (local.get $c)))))
.func_wrap_async(
"test",
"make_commodity",
|mut caller: Caller<'_, ()>, ()| {
let id = Uuid::from_u128((1u128 << 64) | 2u128);
Ok(Some(alloc_commodity_ref(&mut caller, 7, 2, id).await?))
let mut results = [Val::I64(0), Val::I64(0), Val::I64(0), Val::I32(0)];
assert_eq!(results[0].i64(), Some(7), "numer");
assert_eq!(results[1].i64(), Some(1), "commodity_hi");
assert_eq!(results[2].i64(), Some(2), "commodity_lo");
assert_eq!(results[3].i32(), Some(1), "atomic ⇒ null unit-term");
async fn alloc_commodity_ref_errors_without_commodity_new_export() {
// No `commodity_new` export — the host fn must surface the missing-
// export contract violation rather than panic or silently succeed.
(import "test" "try_make"
.func_wrap_async("test", "try_make", |mut caller: Caller<'_, ()>, ()| {
let id = Uuid::from_u128(0);
match alloc_commodity_ref(&mut caller, 1, 1, id).await {
msg.contains("commodity_new"),
"expected commodity_new-missing error, got: {msg}"
"alloc_commodity_ref unexpectedly succeeded without commodity_new",
fn unit_term_algebra_merges_sorts_and_cancels() {
use nomiscript::{Compiler, Reader, SymbolTable};
fn ar(a: Rooted<AnyRef>) -> Val {
Val::AnyRef(Some(a))
// Compile a trivial program just to obtain the skeleton, which exports
// the unit-term helpers. Then exercise the sorted-merge directly: it is
// the riskiest hand-written wasm in ADR-0028 E1/E2.
let mut compiler = Compiler::new();
let mut symbols = SymbolTable::with_builtins();
let program = Reader::parse("0").unwrap();
let (bytes, _) = compiler
.compile_eval_with_type(&program, &mut symbols)
.expect("eval compile");
let module = compile_module(&engine, &bytes).expect("module");
link_nomi_raise_stub(&mut linker, &engine);
store.set_fuel(100_000_000).unwrap();
let instance = linker.instantiate(&mut store, &module).unwrap();
let call_ref = |store: &mut Store<()>, name: &str, args: &[Val]| -> Rooted<AnyRef> {
let f = instance.get_func(&mut *store, name).unwrap();
let mut res = [Val::AnyRef(None)];
f.call(&mut *store, args, &mut res).unwrap();
match &res[0] {
Val::AnyRef(Some(a)) => *a,
other => panic!("{name} returned {other:?}"),
let read_term = |store: &mut Store<()>, t: Rooted<AnyRef>| -> Vec<i64> {
let arr = t.unwrap_array(&mut *store).unwrap();
let len = arr.len(&mut *store).unwrap();
(0..len)
.map(|i| arr.get(&mut *store, i).unwrap().i64().unwrap())
.collect()
let singleton = |store: &mut Store<()>, hi: i64, lo: i64| -> Rooted<AnyRef> {
call_ref(store, "unit_singleton", &[Val::I64(hi), Val::I64(lo)])
let usd = singleton(&mut store, 10, 20);
let eur = singleton(&mut store, 30, 40);
assert_eq!(read_term(&mut store, usd), vec![10, 20, 1]);
assert_eq!(read_term(&mut store, eur), vec![30, 40, 1]);
// Disjoint merge is sorted by (hi,lo), order-independent.
let usd_eur = call_ref(&mut store, "unit_mul", &[ar(usd), ar(eur)]);
assert_eq!(read_term(&mut store, usd_eur), vec![10, 20, 1, 30, 40, 1]);
let eur_usd = call_ref(&mut store, "unit_mul", &[ar(eur), ar(usd)]);
assert_eq!(read_term(&mut store, eur_usd), vec![10, 20, 1, 30, 40, 1]);
// Matching key sums exponents.
let usd2 = call_ref(&mut store, "unit_mul", &[ar(usd), ar(usd)]);
assert_eq!(read_term(&mut store, usd2), vec![10, 20, 2]);
// Cancellation drops the zero-exponent entry → empty (dimensionless).
let canceled = call_ref(&mut store, "unit_div", &[ar(usd), ar(usd)]);
assert_eq!(read_term(&mut store, canceled), Vec::<i64>::new());
// negate flips exponents.
let neg_usd = call_ref(&mut store, "unit_negate", &[ar(usd)]);
assert_eq!(read_term(&mut store, neg_usd), vec![10, 20, -1]);
let eq = |store: &mut Store<()>, a: Rooted<AnyRef>, b: Rooted<AnyRef>| -> i32 {
let f = instance.get_func(&mut *store, "unit_eq").unwrap();
let mut res = [Val::I32(0)];
f.call(&mut *store, &[ar(a), ar(b)], &mut res).unwrap();
res[0].i32().unwrap()
assert_eq!(eq(&mut store, usd, usd), 1);
assert_eq!(eq(&mut store, usd, eur), 0);
// Same multiset, built two ways, compares equal.
assert_eq!(eq(&mut store, usd_eur, eur_usd), 1);
async fn compound_money_arithmetic_end_to_end() {
use nomiscript::{Compiler, HostFnSpec, Reader, SymbolTable, WasmType};
// Two distinct currencies, each produced atomic at 3/1 by a host fn.
const USD: u128 = 0x1111_1111_1111_1111_2222_2222_2222_2222;
const EUR: u128 = 0x3333_3333_3333_3333_4444_4444_4444_4444;
async fn run(src: &str) -> Result<EvalValue, String> {
let specs = vec![
HostFnSpec::new("usd", "test", "usd").returns(WasmType::Commodity),
HostFnSpec::new("eur", "test", "eur").returns(WasmType::Commodity),
HostFnSpec::new("sink", "test", "sink")
.with_params(vec![WasmType::Commodity])
.returns(WasmType::I32),
];
let program = Reader::parse(src).unwrap();
let mut compiler = Compiler::with_host_fns(specs.clone());
symbols.register_host_fns(&specs);
let (bytes, result_ty) = compiler
.map_err(|e| e.to_string())?;
let module = compile_module(&engine, &bytes).map_err(|e| format!("{e:?}"))?;
.func_wrap_async("test", "usd", |mut caller: Caller<'_, ()>, ()| {
Ok(Some(
alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(USD)).await?,
.func_wrap_async("test", "eur", |mut caller: Caller<'_, ()>, ()| {
alloc_commodity_ref(&mut caller, 3, 1, Uuid::from_u128(EUR)).await?,
// The sink only reaches its body for ATOMIC args — a compound arg is
// rejected by the `commodity_assert_atomic` guard before this runs.
"sink",
|mut caller: Caller<'_, ()>, (arg,): (Option<Rooted<StructRef>>,)| {
read_commodity_arg(&mut caller, arg)?;
Ok(0i32)
.instantiate_async(&mut store, &module)
.await
.map_err(|e| format!("{e:?}"))?;
let func = instance.get_func(&mut store, "nomi-eval").unwrap();
func.call_async(&mut store, &[], &mut results)
let any = match &results[0] {
Val::AnyRef(a) => *a,
_ => return Err("nomi-eval returned non-anyref".to_string()),
decode_eval_result(&mut store, any, result_ty).map_err(|e| e.to_string())
// money ÷ money, same currency → dimensionless → decodes as a Number.
assert_eq!(
run("(/ (usd) (usd))").await,
Ok(EvalValue::Ratio { numer: 1, denom: 1 })
// money + money, same currency → atomic money (the null-term fast path).
match run("(+ (usd) (usd))").await {
Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (6, 1)),
other => panic!("expected atomic commodity 6/1, got {other:?}"),
// money + money, different currency → COMMODITY-MISMATCH throw.
assert!(run("(+ (usd) (eur))").await.is_err());
// money × money → compound, no host wire form yet → decode error.
run("(* (usd) (usd))")
.unwrap_err()
.contains("compound")
// an ATOMIC money passes the host-border guard.
assert_eq!(run("(sink (usd))").await, Ok(EvalValue::I32(0)));
// a COMPOUND money is rejected at the host border by the guard.
assert!(run("(sink (* (usd) (usd)))").await.is_err());
// a COMPOUND money riding a $pair cell is rejected by the pair-car
// renderer too — it must NOT slip through as id-zero atomic money
// (the pair-decode border is field-4-aware, same as the top-level one).
run("(list (* (usd) (usd)))")
// an atomic money in a list cell still renders (sanity: the field-4
// gate doesn't reject the null-term atomic case).
assert!(run("(list (usd))").await.is_ok());
// dimensionless × atomic → a `[(usd,1)]` singleton term that
// `commodity_new_with_term` canonicalizes back to ATOMIC usd: it decodes
// as an atomic commodity (3/1) and passes the host-border atomic guard.
match run("(* (/ (usd) (usd)) (usd))").await {
Ok(EvalValue::Commodity { numer, denom, .. }) => assert_eq!((numer, denom), (3, 1)),
other => panic!("expected atomic commodity 3/1, got {other:?}"),
run("(sink (* (/ (usd) (usd)) (usd)))").await,
Ok(EvalValue::I32(0))
/// The eval-mode `CompileContext` declares `nomi.__nomi_raise` for
/// `(error 'code "msg")` lowering even when no `(error)` form is
/// present in the program. The host side lives in the rpc crate;
/// for the scripting-crate runtime tests we link a never-called
/// stub so `instantiate()` resolves the import.
fn link_nomi_raise_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
.func_new(
"nomi",
"__nomi_raise",
wasmtime::FuncType::new(
engine,
[
wasmtime::ValType::Ref(wasmtime::RefType::ARRAYREF),
[],
|_, _, _| {
"__nomi_raise stub: not linked in this test",
link_log_stub(linker, engine);
link_nomi_catch_each_stub(linker, engine);
/// `env.log` `(i32 level, i32 ptr, i32 len) -> ()` stub. Eval-mode modules
/// import `env.log` (PRINT / DISPLAY / NEWLINE / DEBUG lower to it); a test
/// linker that instantiates an eval module must define it or instantiation
/// fails with "unknown import". Production wires it via `scripting::host`
/// (script mode) / `rpc::natives::env_io` (eval mode); this no-op stub
/// suffices for tests that don't assert on logged output.
fn link_log_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
"env",
"log",
wasmtime::ValType::I32,
|_, _, _| Ok(()),
/// Companion to `link_nomi_raise_stub`. The eval compile context now
/// declares `__nomi_catch_each` up-front (so its import index is
/// stable before any user host fn is wired — matches `__nomi_raise`'s
/// shape), so even programs that don't use `(catch-each ...)` still
/// need the import resolvable at instantiation time. The stub traps
/// on call so a regression that accidentally invokes catch-each in
/// these unit tests surfaces loudly rather than silently no-oping.
fn link_nomi_catch_each_stub(linker: &mut Linker<()>, engine: &wasmtime::Engine) {
let abstract_struct =
wasmtime::ValType::Ref(wasmtime::RefType::new(true, wasmtime::HeapType::Struct));
let funcref = wasmtime::ValType::Ref(wasmtime::RefType::FUNCREF);
let anyref = wasmtime::ValType::Ref(wasmtime::RefType::ANYREF);
"__nomi_catch_each",
[funcref, anyref, abstract_struct.clone()],
[abstract_struct],
"__nomi_catch_each stub: not linked in this test",
/// End-to-end: nomiscript Compiler emits eval-mode bytecode that
/// returns the form's final value via nomi-eval's `(ref null any)`
/// return slot, the runtime instantiates it (no capture host fns
/// linked — they retired in A6.c), and `decode_eval_result` walks
/// the anyref into the structured `EvalValue` the rest of the host
/// renders from.
fn run_nomiscript_eval(program: &nomiscript::Program) -> Option<EvalValue> {
use nomiscript::{Compiler, SymbolTable};
.compile_eval_with_type(program, &mut symbols)
store.set_fuel(10_000_000).unwrap();
func.call(&mut store, &[], &mut results).unwrap();
_ => panic!("nomi-eval returned non-anyref"),
Some(decode_eval_result(&mut store, any, result_ty).expect("decode"))
fn nomiscript_eval_captures_integer_literal() {
use nomiscript::{Expr, Fraction, Program};
// ADR-0028: an integer literal is an Index (I32), decoding as `I32`,
// not the dimensionless `Ratio` it conflated with before the flip.
let program = Program::new(vec![Expr::Number(Fraction::from_integer(7))]);
assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(7)));
fn nomiscript_eval_captures_arithmetic_result() {
// All-integer (Index) arithmetic stays in the Index stratum: `(+ 1 2)`
// decodes as `I32(3)`.
let program = Program::new(vec![Expr::List(vec![
Expr::Symbol("+".into()),
Expr::Number(Fraction::from_integer(1)),
Expr::Number(Fraction::from_integer(2)),
])]);
assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::I32(3)));
fn nomiscript_eval_captures_fractional_result() {
// A Scalar operand keeps rational division: `(/ 1/2 2) → 1/4`, decoding
// as `Ratio`. (All-integer `(/ 1 4)` would be Index `0`.)
Expr::Symbol("/".into()),
Expr::Number(Fraction::new(1, 2)),
run_nomiscript_eval(&program),
Some(EvalValue::Ratio { numer: 1, denom: 4 })
fn nomiscript_eval_captures_nil_for_empty_program() {
let program = nomiscript::Program::default();
assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::Nil));
fn nomiscript_eval_decodes_bool_as_bool() {
use nomiscript::{Expr, Program};
let program = Program::new(vec![Expr::Bool(true)]);
// `#t` carries `WasmType::Bool` (i31-boxed); the decoder surfaces a
// truthy bool as `Bool(true)` (a falsy one would be `Nil`), not the
// raw integer the old i32-conflated path produced.
assert_eq!(run_nomiscript_eval(&program), Some(EvalValue::Bool(true)));
/// Drift-detector: compile-eval each script source, run it, and
/// verify the static `result_ty` hint reported by
/// `compile_eval_with_type` matches the decoded `EvalValue`'s
/// variant. Catches eval-vs-codegen drift across the compiler —
/// the exact bug class that produced the tag-sync test regressions.
/// Add a row whenever a new WasmType or Expr-shape is supported.
fn nomiscript_eval_type_hint_matches_value_variant() {
use nomiscript::{Compiler, Program, Reader, SymbolTable, WasmType};
let cases: &[(&str, Option<WasmType>)] = &[
// ADR-0028: integer literals + all-integer arithmetic are Index
// (I32); a fractional literal is Scalar (Ratio).
("42", Some(WasmType::I32)),
("(+ 1 2)", Some(WasmType::I32)),
("(/ 1 4)", Some(WasmType::I32)),
("(/ 1/2 2)", Some(WasmType::Ratio)),
("(= 1 1)", Some(WasmType::Bool)),
("(< 1 2)", Some(WasmType::Bool)),
("#t", Some(WasmType::Bool)),
("\"hello\"", Some(WasmType::StringRef)),
("(let ((x 1)) (+ x 1))", Some(WasmType::I32)),
("(let ((x 1)) \"tail\")", Some(WasmType::StringRef)),
("(if (= 1 1) 2 3)", Some(WasmType::I32)),
for (src, expected_ty) in cases {
let program: Program = Reader::parse(src).expect("parse");
.unwrap_or_else(|e| panic!("compile {src:?}: {e}"));
&result_ty, expected_ty,
"compile_eval_with_type reported wrong static type for {src:?}",
func.call(&mut store, &[], &mut results)
.unwrap_or_else(|e| panic!("run {src:?}: {e}"));
_ => panic!("nomi-eval returned non-anyref for {src:?}"),
let decoded = decode_eval_result(&mut store, any, result_ty)
.unwrap_or_else(|e| panic!("decode {src:?}: {e}"));
// The mapping below is the canonical EvalValue ↔ WasmType
// contract. Any drift fails here.
let ok = matches!(
(&result_ty, &decoded),
(None, EvalValue::Nil)
| (Some(WasmType::I32), EvalValue::I32(_))
// A Bool decodes to Bool(true) when truthy or Nil when falsy.
| (Some(WasmType::Bool), EvalValue::Bool(_) | EvalValue::Nil)
| (Some(WasmType::Ratio), EvalValue::Ratio { .. })
| (Some(WasmType::Commodity), EvalValue::Commodity { .. })
| (
Some(WasmType::StringRef),
EvalValue::String(_) | EvalValue::Bytes(_)
ok,
"type/value drift for {src:?}: hint={result_ty:?}, decoded={decoded:?}",
async fn render_entity_emits_named_field_plist() {
// Build a $commodity-shaped struct (3 string fields, matching the
// Commodity layout's id/symbol/name slots) via WAT, then decode it with
// `render_entity` — the same call the host eval path makes for a returned
// entity. Proves the spec-driven decoder reads each slot by name.
(type $i8 (array (mut i8)))
(type $commodity (struct
(field (ref null $i8))
(field (ref null $i8))))
(data $id "uuid-123")
(data $sym "USD")
(data $name "US Dollar")
(func (export "go") (result (ref null struct))
(array.new_data $i8 $id (i32.const 0) (i32.const 8))
(array.new_data $i8 $sym (i32.const 0) (i32.const 3))
(array.new_data $i8 $name (i32.const 0) (i32.const 9)))))
let linker: Linker<()> = Linker::new(&engine);
let entity = match results[0] {
Val::AnyRef(Some(a)) => a.unwrap_struct(&mut store).unwrap(),
other => panic!("go did not return a struct: {other:?}"),
let rendered =
render_entity(&mut store, entity, nomiscript::EntityKind::Commodity).unwrap();
rendered,
"(:commodity :id \"uuid-123\" :symbol \"USD\" :name \"US Dollar\")"
async fn read_entity_string_field_reads_named_slot() {
// Same Commodity-shaped struct; prove the field reader resolves a named
// slot from the layout (id = slot 0, name = slot 2) — the call
// `draft-split` makes to turn an entity ref into a stable uuid string.
let id = read_entity_string_field_ctx(
&mut store,
entity,
nomiscript::EntityKind::Commodity,
"id",
assert_eq!(id, "uuid-123");
let name = read_entity_string_field_ctx(
"name",
assert_eq!(name, "US Dollar");
// A field that isn't a String slot in the layout is rejected.
let bad = read_entity_string_field_ctx(
"nonexistent",
assert!(bad.is_err());