All pages

Virtual machine

This page specifies qubits-vm, the sandbox contracts run in: the entry point, the wasmi configuration, what validation refuses, how instructions are metered, every gas price and limit, every host call with its exact signature, and how determinism is pinned. Everything here is consensus: the same code and input must give the same result, gas included, on every validator. It is written for auditors and for anyone reading or changing the VM. How a transaction reaches the VM is on Contract model.

Why WebAssembly and wasmi

The chain does not run the EVM. ecrecover would bring back a signature that quantum computers break, and addresses would drop to 20 bytes. Contracts check ML-DSA and SLH-DSA signatures through the verify host call instead.

wasmi is an interpreter. No JIT compiler enters the attack surface, and it has a fuel meter built in. A gas is one unit of wasmi fuel.

Entry point

pub fn run(call: Invocation<'_>, host: &mut dyn Host) -> Result<Finish, Fatal>;

pub struct Invocation<'a> {
    pub code: &'a [u8],
    pub function: &'a str,
    pub input: &'a [u8],
    pub contract: Address,
    pub caller: Address,
    pub value: Amount,
    pub gas: u64,
}

pub struct Finish {
    pub outcome: Outcome,
    pub output: Vec<u8>,
    pub gas_left: u64,
}

/// The node itself failed: a database error, not anything a contract did. Stops the block.
pub struct Fatal(pub String);
FieldMeaning
codeThe module's bytes
functionThe export to run. It must have type () -> ()
inputThe arguments, handed over by input_size and input
contractThe address this run is, used for storage, events and self
callerThe account or contract that called
valueThe native tokens that came along, already in contract's balance
gasThe gas this run may use

run returns Err only for Fatal. Everything a contract can cause is an Ok(Finish). Outcome is defined on Contract model.

Steps

  1. Subtract CALL + code.len() × LOAD_BYTE from gas. On underflow, finish OutOfGas.
  2. Build a new Engine and Module::new the code. This validates and translates it. On failure, finish Invalid.
  3. Create the Store with its limits and link the host calls.
  4. Instantiate. Data and element segments are applied here. On failure, finish Invalid.
  5. Find the memory exported as memory. If there is none, finish Invalid.
  6. Subtract pages × PAGE, using the instantiated size. On underflow, finish OutOfGas.
  7. Find function with type () -> (). If that fails, finish Done when function is init and no export has that name, otherwise Invalid. Either way the remaining gas stays.
  8. Set the fuel to the remaining gas and call the function.
  9. Read the fuel back and take the output buffer. Map the result as below.

Steps 1 to 7 run with no fuel set, so wasmi charges nothing for them. LOAD_BYTE and PAGE pay for that work instead.

Host trait

Host is the chain as a contract sees it. qubits-execution implements it with undo, on Run (see Undo log). tests/vm.rs implements it with a map.

pub trait Host {
    fn height(&self) -> u64;
    fn timestamp_ms(&self) -> u64;
    fn balance(&mut self, who: &Address) -> Result<Amount, Fatal>;
    fn read(&mut self, contract: &Address, key: &[u8]) -> Result<Option<Vec<u8>>, Fatal>;
    fn write(&mut self, contract: &Address, key: &[u8], value: Option<&[u8]>) -> Result<(), Fatal>;
    /// Only a contract's own balance can leave; returns false if it is too low.
    fn transfer(&mut self, from: &Address, to: &Address, amount: Amount) -> Result<bool, Fatal>;
    fn emit(&mut self, contract: &Address, topic: Vec<u8>, data: Vec<u8>);
    fn call(
        &mut self,
        from: &Address,
        to: &Address,
        function: &str,
        args: &[u8],
        value: Amount,
        gas: u64,
    ) -> Result<Called, Fatal>;
}

/// What came of a contract calling another. A failed callee has already been undone.
pub struct Called {
    pub ok: bool,
    pub output: Vec<u8>,
    pub gas_left: u64,
}

Each run has its own frame in the store. The contract argument of read, write and emit, and the from of transfer and call, always come from the frame and never from the contract, which is why a contract cannot reach another's storage or balance.

pub(crate) struct Frame<'h> {
    pub host: &'h mut dyn Host,
    pub limits: StoreLimits,
    pub contract: Address,
    pub caller: Address,
    pub value: Amount,
    pub input: Vec<u8>,
    pub output: Vec<u8>,
    /// What the last contract called gave back.
    pub result: Vec<u8>,
}

wasmi configuration

Version and features

wasmi = { version = "=2.0.0", default-features = false, features = ["std", "validate", "portable-dispatch"] }

The version is exact because gas used goes into receipts, and receipts into blocks. An upgrade is a fork and happens only on purpose.

portable-dispatch is mandatory. It runs instructions in a loop. The other dispatch hopes the compiler turns every instruction into a tail call, and where it does not, an endless wasm loop overflows the native stack and kills the node.

validate enables wasm validation. default-features = false leaves out wasmi's memory64 and auto-dispatch, and simd is not requested. cargo tree -e features shows qubits-vm as the only crate enabling features of wasmi, so nothing else adds them back.

Engine config

let mut config = Config::default();
config
    .consume_fuel(true)
    .floats(false)
    .allow_start_fn(false)
    .wasm_saturating_float_to_int(false)
    .wasm_multi_memory(false)
    .wasm_tail_call(false)
    .wasm_custom_page_sizes(false)
    .wasm_wide_arithmetic(false)
    .set_max_recursion_depth(limits::MAX_RECURSION)
    .compilation_mode(CompilationMode::Eager);
Proposal or settingState
Floats, f32 and f64Off
Start functionsOff
Saturating float to intOff
Multi-memoryOff
Tail callsOff
Custom page sizesOff
Wide arithmeticOff
memory64, SIMD, relaxed SIMDOff, not compiled in
Mutable globalsOn, the wasmi default
Multi-valueOn, the wasmi default
Sign extensionOn, the wasmi default
Bulk memoryOn, the wasmi default
Reference typesOn, the wasmi default
Extended constant expressionsOn, the wasmi default
CompilationEager: the whole module is translated in Module::new
Wasm call depth256 frames, MAX_RECURSION

The configuration is built once, in a OnceLock. Each run clones it into a new engine.

One engine per run

An engine keeps all code it ever translated until it is dropped. One shared engine loses 23 KB a call, and a node dies of its own uptime. So run builds a fresh Engine every time. memory_does_not_grow_with_calls in qubits-execution/tests/gas.rs checks that memory stays flat over 200,000 calls. See Measurements.

Store limits

StoreLimitsBuilder::new()
    .memory_size(limits::MAX_MEMORY_BYTES)
    .memories(1)
    .tables(1)
    .instances(1)
    .build()

trap_on_grow_failure keeps its default of false. memory.grow past the limit gives -1, as the wasm specification says, and the contract can see that. memory_stops_at_the_limit pins it.

table_elements is not set, so a table's size is neither limited nor charged. (table 1000000 funcref) deployed for 7,188 gas in a test. Only governance deploys today, so only reviewed code reaches this.

What is refused

Each of these was deployed on a test chain with 2,000,000 gas. All ended Invalid, with gas_left 0, and the deployment left nothing behind.

ModuleRefused because
Bytes that are not wasmParsing
A float instruction, or a function with a float parameterFloats are off
A start sectionStart functions are off
i32.trunc_sat_f32_sSaturating float to int is off
return_callTail calls are off
A v128 valueSIMD is not compiled in
A 64-bit memorymemory64 is not compiled in
Two memoriesMulti-memory is off
Two tablesThe store allows one
An import from any module but qubitsLinking
A qubits import with the wrong signatureLinking
An imported memoryLinking: the linker provides functions only
No memory exported, or one exported under another nameNo memory export
65 initial pagesAbove MAX_MEMORY_BYTES. 64 pages deploy
A data or element segment out of boundsInstantiation

code_that_could_differ_between_machines_is_refused in tests/contracts.rs pins part of this list.

A function that is exported with parameters or a result cannot be called. The module is valid, and a call of that function ends Invalid with the remaining gas kept. init with the wrong type does the same, and the deployment fails.

Modules using multi-value, bulk memory, sign extension, reference types, mutable globals or extended constants deployed Done.

Instruction metering

wasmi charges fuel when it enters a basic block, for the whole block. The translator opens each block with a ConsumeFuel instruction of at least 1, then adds the cost of each operator.

OperatorsCost
nop, drop, block, loop, unreachable, return, else, end0
Every other operator, including local.get and i32.const1
Each ConsumeFuel1
Bulk memory (memory.copy, memory.fill, memory.init)Also length / 64, rounded down
memory.growAlso added bytes / 64, which is 1,024 per page

These are wasmi's defaults, and run does not change them. So the gas of a module of L bytes with p pages whose function does nothing is 2,000 + 16 × L + 500 × p + 1. Measured: 3,237 gas for 46 bytes and one page, and 3,737 with two pages.

Three i64.add statements, each local.get, i64.const, i64.add, local.set, cost 13 fuel in one block: 1 for the block and 4 for each statement. memory.fill of 6,400 bytes adds 100 gas to the operator's own cost, and each memory.grow of one page adds 1,024.

Pages are priced differently depending on when they appear. A page present at instantiation costs PAGE (500) on every run. A page added by memory.grow costs 1,024, charged by wasmi, and no PAGE.

Metering happens at block entry, so a block already paid for runs to its end even if a host call inside it leaves the fuel at 0. A contract whose callee burned all the gas finished Done when nothing metered followed in that block, and OutOfGas when it entered another.

Gas table

All prices are in qubits_vm::gas, and all are part of the on-chain format. A gas is about one interpreted instruction: 0.5 ns on the fast core these were measured on. Every price keeps the work under about 1.5 ns a gas, so a slow cloud core stays near 5.

ConstantGasPays for
HOST100Crossing into the host at all. No host call is free
CALL2,000Starting any run, and reaching for another contract
LOAD_BYTE16Reading the code and translating it, per byte
PAGE500Instantiating a page of 64 KiB of memory
COPY_BYTE1Copying between the contract's memory and the host, per byte
READ20,000A storage or balance read, priced for a read that misses the cache on a network disk, not for the usual one
WRITE15,000A storage write or delete
STATE_BYTE64A byte kept in state for good, per byte of key and value written
TRANSFER20,000Moving native tokens
EVENT500An event
EVENT_BYTE8A byte of topic or data in an event
HASH500The hash host call
VERIFY_ML_DSA100,000Verifying an ML-DSA-65 signature
VERIFY_SLH_DSA800,000Verifying an SLH-DSA-SHA2-192s signature

A price change changes gas counts, so it is a fork. Deleting storage returns no gas: state pays by the byte written, and del is one WRITE.

Limits

These are in qubits_vm::limits. What a single run may hold:

ConstantValueApplies to
MAX_MEMORY_BYTES4,194,304 (4 MiB, 64 pages)Linear memory
MAX_KEY_BYTES128A storage key
MAX_VALUE_BYTES16,384 (16 KiB)A storage value. Also the input of hash, and the message of verify
MAX_TOPIC_BYTES64An event's topic
MAX_EVENT_BYTES4,096 (4 KiB)An event's data
MAX_OUTPUT_BYTES16,384 (16 KiB)output, equal to MAX_ARGS_BYTES
MAX_RECURSION256Wasm call depth within a run

A revert reason may be up to MAX_TOPIC_BYTES × 4, which is 256 bytes.

These are in qubits_types::limits:

ConstantValue
MAX_ARGS_BYTES16,384
MAX_FUNCTION_NAME64
MAX_CODE_BYTES163,840
MAX_CALL_DEPTH8
MAX_TX_GAS5,000,000
MAX_BLOCK_GAS10,000,000

Host calls

A contract reaches the chain only through the imports of the qubits module. There are 20. They and their signatures are part of the on-chain format.

(import "qubits" "input_size"   (func (result i32)))
(import "qubits" "input"        (func (param i32)))         ;; ptr
(import "qubits" "output"       (func (param i32 i32)))     ;; ptr len
(import "qubits" "revert"       (func (param i32 i32)))     ;; ptr len, does not return
(import "qubits" "caller"       (func (param i32)))         ;; ptr
(import "qubits" "self"         (func (param i32)))         ;; ptr
(import "qubits" "value"        (func (param i32)))         ;; ptr
(import "qubits" "height"       (func (result i64)))
(import "qubits" "timestamp_ms" (func (result i64)))
(import "qubits" "balance"      (func (param i32 i32)))     ;; who out
(import "qubits" "get"          (func (param i32 i32 i32 i32) (result i32)))  ;; key key_len out cap
(import "qubits" "set"          (func (param i32 i32 i32 i32)))               ;; key key_len value value_len
(import "qubits" "del"          (func (param i32 i32)))     ;; key key_len
(import "qubits" "transfer"     (func (param i32 i32) (result i32)))          ;; to amount
(import "qubits" "emit"         (func (param i32 i32 i32 i32)))               ;; topic topic_len data data_len
(import "qubits" "call"         (func (param i32 i32 i32 i32 i32 i32) (result i32)))  ;; to name name_len args args_len value
(import "qubits" "result_size"  (func (result i32)))
(import "qubits" "result"       (func (param i32)))         ;; ptr
(import "qubits" "hash"         (func (param i32 i32 i32))) ;; ptr len out
(import "qubits" "verify"       (func (param i32 i32 i32 i32 i32 i32 i32) (result i32)))  ;; scheme key key_len msg msg_len sig sig_len

Charges

Every host call charges before it does anything, so whatever it allocates is already paid for. In the table, copy(n) means HOST + n × COPY_BYTE, one copy across the boundary.

CallGas
input_sizeHOST
inputcopy(input length)
outputcopy(len)
revertcopy(len)
caller, selfcopy(32)
valuecopy(16)
height, timestamp_msHOST
balanceREAD + copy(32) + copy(16)
getREAD + copy(key_len), plus copy(bytes copied) when the key exists
setWRITE + (key_len + value_len) × STATE_BYTE + copy(key_len) + copy(value_len)
delWRITE + copy(key_len)
transferTRANSFER + copy(32) + copy(16)
emitEVENT + (topic_len + data_len) × EVENT_BYTE + copy(topic_len) + copy(data_len)
callCALL + 2 × READ + copy(32) + copy(name_len) + copy(args_len) + copy(16), then what the callee uses
result_sizeHOST
resultcopy(result length)
hashHASH + copy(len) + copy(32)
verifyVERIFY_ML_DSA or VERIFY_SLH_DSA, then copy(key_len) + copy(msg_len) + copy(sig_len)

call charges two reads whether or not they find anything, for finding the callee and its code.

Measured against a run with the same imports and no call, less 16 gas for each added byte of code, these formulas match to within the few instructions that push the arguments. set with a 10-byte key and a 20-byte value is 17,150, measured at 17,155 with four i32.const and the call. emit of 8 and 80 bytes is 1,492, measured at 1,497. hash of 64 bytes is 796, measured at 800. verify of nothing with ML-DSA-65 is 100,300, measured at 100,308.

Failure modes

A host call stops a contract in one of four ways:

pub(crate) enum Stop {
    Revert(Vec<u8>),
    OutOfGas,
    /// Broke a limit or handed the host something that makes no sense.
    Misuse,
    Fatal(Fatal),
}

Misuse becomes Trapped. A pointer range outside memory is Misuse in every call that reads or writes memory.

CallStops withWhen
output, revertMisuselen over 16,384, or over 256 for revert
get, delMisusekey_len over 128
setMisusekey_len over 128, or value_len over 16,384
emitMisusetopic_len over 64, or data_len over 4,096
callMisusename_len over 64, a name that is not UTF-8, or args_len over 16,384
hashMisuselen over 16,384
verifyMisusescheme not 1 or 2, a key or signature longer than the scheme's, or msg_len over 16,384
AnyOutOfGasThe charge is more than the gas left
balance, get, set, del, transfer, callFatalThe database failed

The order of checks differs and is consensus. load checks its limit before it charges, so output with a huge length is Trapped. set and emit charge for key_len + value_len first, so lengths of 0xFFFFFFFF end OutOfGas, not Trapped, and neither overflows or allocates. hostile_host_calls_stop_the_contract_and_not_the_node pins both.

Lengths from a contract are u32. The host adds them as u64s with saturating multiplication, because a hostile contract could otherwise panic the node through overflow checks.

Returns

CallReturns
input_sizeThe length of the arguments
height, timestamp_msThe block's height and time, as the Host gives them
get-1 if the key has no value, otherwise the value's full length. At most cap bytes are copied, so a result above cap means it was cut
transfer0 if it went, 1 if not
call0 if the callee finished, 1 if it failed, in which case everything it did is undone
result_sizeThe length of the last callee's output
verify0 if the signature is good, 1 if not

transfer sends from this contract's own balance. It returns 1 if the balance is too low or the recipient's balance would overflow. With amount 0 or the contract itself as recipient, it returns 0 and changes nothing, provided the balance covers amount. It creates the recipient's account if needed and runs no code there.

call gives the callee all the gas left. Its output replaces result. A failed callee's output is empty. See Calls between contracts.

output may be called several times, and the last wins. It does not end the run. The output counts only if the run ends Done.

hash is contract_hash: BLAKE3 in derive-key mode with the context qubits.sh 2026-11 contract hash, so its result is not plain BLAKE3. It writes 32 bytes to out.

verify checks a raw signature over msg in Domain::Message, whose FIPS context string is qubits/message/v1. This is the domain qubits sign uses, so a transaction signature does not pass, as a_contract_checks_a_post_quantum_signature shows.

schemeAlgorithmKey bytesSignature bytes
1ML-DSA-651,9523,309
2SLH-DSA-SHA2-192s4816,224

A key or signature shorter than these returns 1 and still pays. Longer ones are Misuse. Both are raw bytes, without the leading SchemeId of the tagged forms that qubits key show and qubits sign print. See Cryptography.

Memory conventions

The module must export its linear memory as memory. Pointers are i32 values read as u32 byte offsets. Nothing needs alignment, because the host copies bytes.

ValueLayout
Address32 bytes
Amount16 bytes, u128 little-endian
Hash32 bytes
height, timestamp_msi64 results, u64 values

The host never allocates in the contract's memory. The contract gives a pointer to room it owns: 32 bytes for caller, self and hash, 16 for value and balance, input_size bytes for input, result_size bytes for result, and cap for get.

Outcomes and gas

Finish::early ends a run with an empty output, so only Done carries one. revert is the one way a running contract stops and hands unused gas back.

CauseOutcomegas_left
The function returnedDoneWhat remains
init was run and no export has that nameDoneWhat remains after loading
The contract called revertReverted with its bytesWhat remains
Refused by enter: re-entry, depth, valueReverted with text from the chainAll of it, since nothing ran
Gas below CALL + code.len() × LOAD_BYTE, or below the page costOutOfGas0
Fuel ran out in wasmOutOfGas0
A host call's charge failedOutOfGas0
A wasm trap: unreachable, division by zero, access outside memory, recursion past 256Trapped0
Stop::MisuseTrapped0
The module fails validation, instantiation, or has no memory exportInvalid0
No such export, or an export of the wrong typeInvalidWhat remains after loading
No code at the address, in enterInvalidAll of it

stopped reads the error as a trap code first, then tries to downcast it to Stop, and treats anything else as Trapped. wasmi's error text goes nowhere: nothing it says in words reaches a result, because Outcome is an enum and receipts are hashed into the block. A Reverted reason is bytes the contract chose, or one of the two fixed strings from enter.

Determinism

The same code and input must give the same result on every validator, so these are fixed:

  • The interpreter version, to the patch level, and with it the fuel cost of each instruction.
  • The compiler, by chain/rust-toolchain.toml, at 1.97.1. Release builds keep overflow checks on.
  • Floats are refused, so no NaN bits or rounding modes can differ.
  • The engine configuration is one static value, and no run shares an engine or store with another.
  • A contract sees only what Host gives it: height, block time, its own storage, balances, its call. There is no randomness and no wall clock.
  • All wasm is translated before metering starts, so translation adds no fuel at run time.

Golden tests

qubits-vm/tests/vm.rs runs the sandbox on the simplest host there is, a BTreeMap with constant height 7, time 1,000 and balance 5, whose call returns output [1] and costs nothing. Each run gets 5,000,000 gas. The numbers are the point: if one changes, on any machine, nodes running different builds disagree. A change there is a fork and must be meant.

TestWhat runsPinned
the_token_costs_exactly_thistoken.wasm: init, transfer, balance_of, a transfer that revertsGas 252,105, 190,562, 130,597 and 130,952, and a fingerprint
the_market_costs_exactly_thisamm.wasm: add_liquidity and swap on reserves near 2^111, where the 128-bit multiplication and long division run in 64-bit wasm instructionsGas 522,451 and 385,760, the swap's output, and a fingerprint
instructions_cost_exactly_thisA 10,000-round loop with function calls and i64.store, then memory.fill, memory.copy, memory.growGas 290,478
running_out_of_gas_stops_at_the_same_placeA loop of set until the gas runs out317 writes stored

A fingerprint hashes everything the runs left behind: each storage key and value in key order, each event's topic and data, then each run's output and gas_left as little-endian bytes, all through contract_hash.

FingerprintValue
Tokenbd0b2efd98a3152ecd1fbbd2076b0d21f08c1cac2b360863ee30c5ab07df9a65
Market9d2b1b32fbe62df47bf533e503c41e13b33a96951ef25c27e028ae3b8fd1ca9c

Because the tests include_bytes! the files in chain/contracts/out/, rebuilding a contract with different bytes changes them too.

These results have been checked equal on arm64 and x86-64, with the same test built for x86_64-apple-darwin:

cargo test -p qubits-vm --test vm                               # native
cargo test -p qubits-vm --test vm --target x86_64-apple-darwin  # under Rosetta

Measurements

qubits-execution/tests/gas.rs measures what gas buys on a real RocksDB store, with 200,000 keys in one contract's storage. Each row is a contract doing one expensive thing until MAX_TX_GAS runs out. A row far above the others is a price that is too low, and what a full block can be made to cost.

cargo test --release -p qubits-execution --test gas -- --ignored --nocapture --test-threads=1

This run was on an Apple M5 Max with 18 cores and 48 GiB of memory, macOS, rustc 1.97.1, release profile, with other work running on the machine. Rates are from one run.

WorkGasmsns per gasFull block, ms
branch5,000,0002.600.525.2
arithmetic5,000,0002.020.404.0
memory load and store5,000,0002.130.434.3
memory.fill of 60 KB5,000,0002.610.525.2
wasm function call5,000,0003.770.757.5
storage read, present5,000,0001.310.262.6
storage read, absent5,000,0000.920.181.8
account balance5,000,0000.450.090.9
hash of nothing5,000,0000.960.191.9
hash of 16 KB5,000,0002.110.424.2
event5,000,0000.580.121.2
output5,000,0001.710.343.4
failed call, no contract5,000,0000.300.060.6
call of the largest code5,000,0003.100.626.2
call of the largest memory5,000,0001.910.383.8
ML-DSA-65 signature5,000,0003.890.787.8
SLH-DSA signature5,000,0003.760.757.5
state root, 666 writes9,990,00013.171.3213.2

"Full block" scales the rate to MAX_BLOCK_GAS. The state root row hashes a block's writes into the tree, which is the slowest work a block can ask for. The largest row among the runs themselves is 0.78 ns a gas. call of the largest code installs a module of about 160 KiB directly in state, which no deployment could.

The second test made 200,000 balance_of calls on the 6,677-byte token after 20,000 warm-up calls. Resident memory was 279,424 KB before and 275,328 KB after, at 95.6 µs a call.

Source

  • chain/crates/qubits-vm/src/lib.rs: run, Invocation, Finish, Host, gas, limits, the engine configuration
  • chain/crates/qubits-vm/src/host.rs: every host call, charge, load, store, linker
  • chain/crates/qubits-vm/Cargo.toml: the wasmi pin and its features
  • chain/crates/qubits-vm/tests/vm.rs: the golden tests
  • chain/crates/qubits-execution/tests/gas.rs: the measurements
  • chain/crates/qubits-execution/tests/contracts.rs: hostile code, limits, what is refused
  • chain/crates/qubits-crypto/src/scheme.rs: SchemeId, Domain
  • chain/crates/qubits-types/src/lib.rs: limits
  • chain/rust-toolchain.toml: the compiler