Skip to content

Repository files navigation

zbpf

A standalone, cluster-exact sBPF (Solana BPF) virtual machine, written entirely in Zig.

zbpf loads, verifies, and executes Solana sBPF (.so) programs with no validator, no Bank, no AccountsDb, no ledger, and no consensus machinery in the loop. Give it a program ELF, an instruction, and a set of account snapshots; it hands back the program's return code (r0), its captured sol_log_* output, the exact number of compute units it consumed, and the per-account mutations the instruction produced. It is a value-in/value-out library, not a service or a daemon.

It is not a clean-room toy interpreter written to approximate Solana's VM. It is extracted, module for module, from the sBPF engine inside the live Vexor validator (the independent, Zig-native Solana validator that votes on Solana testnet in production), whose execution semantics and compute-unit accounting are audited byte-for-byte against anza-xyz/agave @ 4.2.0-beta.0. The interpreter, the verifier, the ELF loader, and the input-region serializer in this repository are the same code that engine runs; the CU number zbpf reports for a given program/input pair is the CU number the validator would report for the same pair.

Table of contents

Why zbpf exists

As of this writing there is no standalone sBPF virtual machine package in the Zig ecosystem:

Project Language Standalone package?
anza-xyz/sbpf Rust Yes, but not Zig
solana-labs/solana-sbpf Rust Yes, but not Zig
Firedancer's fd_vm C Yes, but not Zig
Syndica sig's VM Zig Embedded in a full validator, not a redistributable package
Vexor's vex_bpf2 Zig Embedded in a full validator, not a redistributable package
zbpf Zig Yes — this repository

Every other Zig implementation of the sBPF VM that exists today is welded into a full validator binary: it imports Bank, AccountsDb, replay-stage plumbing, and native-program builtins, and it cannot be pulled out and zig build-ed on its own. zbpf is the extraction of that VM core into its own package: a genuine "just give me the VM" dependency for the Zig ecosystem, with the same execution and CU-accounting fidelity as a production validator, and none of the validator's operational weight.

Who it's for

  • Fuzzers targeting sBPF opcode semantics, the ELF/verifier pass, or the input-region serializer: zbpf gives a fuzz harness a real interpreter to drive without standing up a validator.
  • Program-test tooling in the shape of LiteSVM / solana-program-test: anything that wants to execute one instruction against synthetic account state and inspect the result, without a ledger or a running cluster.
  • Education and experimentation with the Solana VM: a small, inspectable, pure-Zig codebase to read and step through.
  • Cross-chain / other-runtime tooling that wants to reuse sBPF as a bytecode format independent of Solana's native programs (the pluggable NativeProgramResolver, below, exists precisely for this use case).
  • The Vexor Zig SDK's dual-oracle conformance suite: zbpf is a second, independent execution oracle a Zig SDK can diff its own local-execution results against, distinct from the validator itself.

The headline feature: cluster-exact CU accounting

Local/offline sBPF runtimes are common; a cluster-exact compute-unit meter is not. zbpf's differentiator is that its compute metering is not an approximation of Agave's: it is the audited product of the Agave-4.2 CU-parity work carried by the Vexor validator, which closed roughly twenty distinct metering divergences (hash-syscall cost, sol_big_mod_exp stub behavior, per-CPI INVOKE_UNITS, heap-cost wiring, loader entry costs, and more) against anza-xyz/agave @ 4.2.0-beta.0. When zbpf reports consumed_cu, that number is meant to match what the Solana cluster itself would charge for the same program and inputs, not a ballpark, not "close enough for a fuzzer."

This is proven in-repository, not just asserted: the initial load/verify/run known-answer test (src/test_hello_kat.zig) asserts consumed_cu == 105 for the hello_zig.so fixture, matching the number the in-tree Vexor program_test.zig harness reports for byte-identical inputs. Any change that moves that number is treated as a metering regression, not a number to "fudge" (see the comment directly above REFERENCE_CU in that file).

Quickstart

Add the dependency

zbpf is fetched as a Zig package dependency from GitHub. In your build.zig.zon:

.dependencies = .{
    .zbpf = .{
        .url = "https://github.com/DavidB-77/zbpf/archive/refs/heads/master.tar.gz",
        // .hash = "...", // filled in by `zig fetch --save` on first fetch
    },
},

or simply run:

zig fetch --save https://github.com/DavidB-77/zbpf/archive/refs/heads/master.tar.gz

zbpf itself depends on exactly one package: zolcrypt, a standalone, FFI-free, pure-Zig crypto library (ed25519 / bn254 / poseidon / secp256k1 / hashes) extracted alongside zbpf from the same validator. You do not need to depend on zolcrypt directly — Zig's package manager resolves it transitively — but its presence is why zbpf has no blst/C link dependency for anything except the BLS12-381 syscall path (a real, blst-FFI-linked, KAT-verified implementation — see Scope and status).

Then wire the module import in your build.zig:

const zbpf_dep = b.dependency("zbpf", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("zbpf", zbpf_dep.module("zbpf"));

Run a program

This is the real shape of the initial load/verify/run known-answer test in src/test_hello_kat.zig, trimmed for the README — load a Zig-SDK "hello world" .so, run it, and read back the result:

const std = @import("std");
const zbpf = @import("zbpf");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    const elf_bytes = try std.fs.cwd().readFileAlloc(
        alloc,
        "tests/bpf_fixtures/hello_zig.so",
        4 * 1024 * 1024,
    );
    defer alloc.free(elf_bytes);

    // One writable, signing fee-payer-shaped account; hello_zig.so doesn't
    // touch it, but a realistic call always carries at least one account.
    const accounts = [_]zbpf.AccountSnapshot{.{
        .pubkey = [_]u8{0x11} ** 32,
        .lamports = 1_000_000,
        .owner = [_]u8{0} ** 32,
        .executable = false,
        .rent_epoch = std.math.maxInt(u64),
        .data = &.{},
        .is_writable = true,
        .is_signer = true,
    }};

    var result = try zbpf.run(alloc, .{
        .program_id = [_]u8{0x22} ** 32,
        .elf_bytes = elf_bytes,
        .ix_data = &.{},
        .accounts = &accounts,
        .compute_budget = zbpf.DEFAULT_COMPUTE_BUDGET, // 1_400_000
        .slot = 0,
    });
    defer result.deinit();

    std.debug.print("success={} r0={d} consumed_cu={d}\n", .{
        result.success(), result.return_code, result.consumed_cu,
    });
    for (result.logs) |line| std.debug.print("  log: {s}\n", .{line});
    for (result.mutations) |m| std.debug.print("  mutated account: {x}\n", .{m.pubkey});

    // Prints:
    //   success=true r0=0 consumed_cu=105
    //     log: Hello world from Zig!
}

hello_zig.so is a solana-zig-compiled hello-world (EM_SBF / sBPF v0) whose entrypoint ignores the input region entirely, calls sol_log_("Hello world from Zig!"), and returns 0 — it is the ship-in-repo fixture at tests/bpf_fixtures/hello_zig.so, used by the initial load/verify/run gate test.

Reusing a Session across multiple runs

zbpf.run is a one-shot convenience that builds a Session, runs it once, and tears the program cache down. If you're executing many instructions against the same program (a fuzz loop, a test-tool driving many transactions), keep a Session alive so the ELF load + verify pass is cached across calls:

var session = zbpf.Session.init(alloc);
defer session.deinit();

for (test_cases) |case| {
    var result = try session.run(.{
        .program_id = program_id,
        .elf_bytes = elf_bytes, // same bytes each call -> cache hit after run 1
        .ix_data = case.ix_data,
        .accounts = case.accounts,
    });
    defer result.deinit();
    // ... inspect result ...
}

The public API

Everything below is re-exported at the package root (@import("zbpf")); see src/root.zig for the authoritative export list.

zbpf.Session

pub const Session = struct {
    pub fn init(alloc: std.mem.Allocator) Session;
    pub fn deinit(self: *Session) void;
    pub fn run(self: *Session, opts: RunOptions) RunError!RunResult;
};

Owns a V2ProgramCache (keyed by program pubkey) for its lifetime, so repeated runs against the same program ELF skip re-parsing and re-verifying it.

zbpf.run

pub fn run(alloc: std.mem.Allocator, opts: RunOptions) RunError!RunResult;

One-shot convenience: builds a Session, calls .run(opts) once, tears the cache down. This is what the quickstart example above uses.

zbpf.RunOptions

Field Type Default Meaning
program_id [32]u8 The executing program's pubkey.
elf_bytes []const u8 The program .so bytes.
ix_data []const u8 &.{} Instruction data passed to the program.
accounts []const AccountSnapshot &.{} Ordered account list for this instruction.
compute_budget u64 DEFAULT_COMPUTE_BUDGET (1_400_000) Starting CU budget.
slot u64 0 Slot value used for program-cache staleness checks.
heap_region_bytes u32 MAX_HEAP_FRAME_BYTES (256 * 1024) VM heap region size (the production caller size).
requested_heap_bytes u32 MIN_HEAP_FRAME_BYTES (32 * 1024) Heap size used for the CU charge — the 32 KiB default costs 0 extra CU (no RequestHeapFrame).
feature_set FeatureSet .{} Feature-activation posture (runtime-configurable, from the CPI-resolver-seam phase). Default is the initial pre-activation snapshot (every SIMD gate off, CPI nesting 5). See zbpf.FeatureSet below.
native_resolver ?NativeProgramResolver null Caller-supplied native-program CPI resolver (from the CPI-resolver-seam phase). null = the default no-native-programs resolver. See zbpf.NativeProgramResolver.

zbpf.RunResult

Field Type Meaning
return_code u64 Program's r0. 0 = success; non-zero = a program-level revert.
consumed_cu u64 Compute units consumed. Exact on success; on failure/revert the full budget is reported, matching Agave's "consumed N of N" on exhaustion or abort.
cu_is_full_budget bool true when consumed_cu is the full-budget failure fallback rather than an exact count.
dispatch_error ?[]const u8 null on a clean EXIT; otherwise the named RunError that stopped dispatch.
sbpf_version []const u8 Detected sBPF version tag ("v0".."v3").
mutations []AccountMutation One entry per writable account whose lamports, data, or owner changed.
logs [][]u8 Captured sol_log_-family program output lines, in emission order.

Plus two methods: .success() (dispatch_error == null and return_code == 0) and .deinit() (frees mutations and logs; must be called by the caller — RunResult owns heap allocations).

zbpf.AccountSnapshot / zbpf.AccountMutation

Package-local value types with no validator coupling — no Bank, no AccountsDb reference anywhere in their definition:

pub const AccountSnapshot = struct {
    pubkey: [32]u8,
    lamports: u64,
    owner: [32]u8,
    executable: bool = false,
    rent_epoch: u64 = std.math.maxInt(u64),
    data: []const u8 = &.{},   // duped into a fresh writable buffer by the engine
    is_writable: bool = false,
    is_signer: bool = false,
};

pub const AccountMutation = struct {
    pubkey: [32]u8,
    new_lamports: u64,
    owner: [32]u8,
    data: []u8,
    new_owner: ?[32]u8,   // non-null only if ownership actually changed
};

zbpf.FeatureSet

A plain struct of Agave SIMD feature-gate booleans, surfaced as a runtime config (in the CPI-resolver-seam phase) so an embedder can choose an activation posture at runtime instead of inheriting a value baked into the binary. The default (.{}) is the initial pre-activation snapshot — every gate false, CPI nesting depth 5 — which is exactly what makes the checked-in CU constants (105, 153) reproducible; supplying it is optional. Field names mirror the InvokeContext gate booleans 1:1 (vasa, direct_mapping, direct_account_pointers, sha512_syscall, alt_bn128_little_endian, alt_bn128_g2, poseidon_enforce_padding, enable_bls12_381_syscall, stake_bpf, alt_bpf, raise_cpi_nesting_limit_to_8, syscall_param_addr_restrict). This replaces the earlier compile-time SIMD_PORT_FORCE_OFF_* constants from the initial load/verify/run phase.

var result = try zbpf.run(alloc, .{
    .program_id = pid,
    .elf_bytes = elf,
    .accounts = accounts,
    .feature_set = .{ .direct_mapping = true }, // opt into one gate; rest stay at the initial default
});

zbpf.NativeProgramResolver / zbpf.defaultResolver

A pluggable vtable interface (mirroring the shape of the syscall registry's own asTrait()) that decides how a cross-program invocation into a native program (System, Vote, Stake, Config, ComputeBudget, AddressLookupTable, ZkElGamalProof) is served:

pub const NativeProgramResolver = struct {
    ctx: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        is_builtin: *const fn (ctx: *anyopaque, program_id: *const [32]u8) bool,
        dispatch: *const fn (
            ctx: *anyopaque,
            ic_ctx: *InvokeContext,
            program_id: *const [32]u8,
            ix_data: []const u8,
        ) ResolverError!void,
    };
};

pub fn defaultResolver() NativeProgramResolver;

zbpf.defaultResolver() is what Session/run wire in when RunOptions.native_resolver is null: is_builtin returns false for every program id, and dispatch always returns error.UnsupportedProgram. This means a CPI into a Solana native program fails cleanly and explicitly rather than silently mis-executing — important for a fuzzer or a non-Solana embedder that has no business running Solana's System/Vote/Stake logic at all. Pure-BPF programs (anything that doesn't CPI into a native program) run unaffected.

The CPI-resolver-seam phase wired this end-to-end. The resolver is now threaded through InvokeContext (native_resolver_ctx / native_resolver_vtable), and cpi.zig's native-vs-BPF routing consults the caller-supplied resolver rather than a hardwired builtins module — set RunOptions.native_resolver to your own implementation and a CPI into a program your resolver claims as a builtin is dispatched to it. The in-repo gate from that phase (src/test_cpi_resolver_kat.zig) proves this with a mock resolver: a real sol_invoke_signed CPI (from the cpi_invoke_zig.so fixture) into a synthetic native id is routed to the mock and returns r0 = 0, while the default resolver reports that same id is not a builtin.

The builtins-bridge phase ships the real bridge. zbpf.builtinsResolver() wraps Vexor's real builtins/ layer (System / Vote / Stake / Config / ComputeBudget / AddressLookupTable / ZkElGamalProof / FeatureGate) behind this same interface, so a CPI into a real native program dispatches to the real handler. Pass it as RunOptions.native_resolver for Solana native-program semantics:

var result = try zbpf.run(alloc, .{
    .program_id = pid,
    .elf_bytes = elf,
    .accounts = accounts,
    .native_resolver = zbpf.builtinsResolver(), // real System/Vote/Stake/… handlers
});

The null-default is still the no-native defaultResolver() (the fuzzer / non-Solana-embedder posture above) — the real builtins are strictly opt-in. The builtins-bridge-phase gates (src/test_builtins_bridge_kat.zig) drive System (a Transfer that moves lamports 100/0 → 70/30) and ComputeBudget (its declared 150-CU charge) through the real bridge, and run the vendored builtins layer's own ~232 inline KATs against zbpf's core; zbpf.builtinsSelfTest() reports all eight native programs internally consistent. See Roadmap.

zbpf.vm.* — the lower-level VM surface

For advanced embedders who need to go under the Session API — a fuzzer driving the verifier directly, a conformance harness that wants to build its own memory map — every vendored VM module is re-exported under zbpf.vm: elf, memory, verifier, interpreter, serialize, invoke_ctx, sysvar_cache, syscalls, cpi, v2_program_cache, dispatch_mode, trace. See Module inventory for what each one does.

Architecture

zbpf.Session.run (implemented by runMetered / dispatchBpf in src/session.zig) drives one instruction through this pipeline:

   .so bytes                      AccountSnapshot[]
       │                                 │
       ▼                                 ▼
 ┌───────────┐   verify   ┌───────────┐  build   ┌──────────────────┐
 │  elf.zig  │──────────▶│verifier.zig│         │ TxCtxOwned/       │
 │  (load)   │  bytecode  │  (checks   │         │ TransactionContext│
 └───────────┘   pass     │  the ISA)  │         │ (invoke_ctx.zig)  │
       │                  └───────────┘         └────────┬──────────┘
       │ cached in V2ProgramCache                          │
       ▼                                                    ▼
 ┌────────────┐    ix data +     ┌────────────────────┐
 │serialize.zig│──accounts──────▶│  5-region memory map │
 │(input region)│                │    (memory.zig)      │
 └────────────┘                 │  text/rodata, stack,  │
                                  │  heap, input region   │
                                  └──────────┬────────────┘
                                             ▼
                     ┌──────────────────────────────────────────┐
                     │           interpreter.zig (the Vm)         │
                     │  per-opcode execution, v0–v3 dispatch,      │
                     │  driven by:                                 │
                     │   • syscalls.zig  — 42-entry sol_* registry  │
                     │   • cpi.zig       — CPI push/translate/pop   │
                     │   • sysvar_cache.zig — Clock/Rent/EpochSched. │
                     │   • InvokeContext — the compute meter         │
                     └──────────────────────┬───────────────────────┘
                                             │ r0, CU consumed, logs
                                             ▼
                              serialize.zig (deserializeReturn)
                                             │
                                             ▼
                                AccountMutation[] + RunResult

Concretely, dispatchBpf in session.zig performs these steps for every call:

  1. Resolve + cache the executable. On a cache miss, elf.Executable.load parses the ELF and verifier.verify runs the bytecode verifier over it (opcode legality, jump-target bounds, register discipline); the result is stored in the session's V2ProgramCache keyed by program pubkey so a second call with the same program id skips both passes.
  2. Serialize the input region. serialize.serializeParametersAligned lays out the program id, instruction data, and every account's lamports/owner/data/flags into the byte layout the sBPF ABI expects (the same routine — and the same byte output — as the validator's production serializer).
  3. Build the memory map. A 5-region AlignedMemoryMap (memory.zig) is constructed per sBPF version: program text, read-only data, a (possibly gapped, per sBPF v0) call stack, the heap, and the serialized input region — each at its own fixed virtual address (MM_* constants).
  4. Build the syscall registry. syscalls.SyscallRegistry.init builds a version-gated table of the ~42 sol_* syscalls the interpreter can call into (logging, memory ops, hashing, curve/crypto ops, sysvar reads, CPI entry points, …).
  5. Build the InvokeContext. The compute meter, the instruction stack (CPI depth), the sysvar cache, and every SIMD feature-gate boolean live here (invoke_ctx.zig). The initial load/verify/run phase sets every feature gate to its pre-activation (false) value — see Scope and status.
  6. Run the Vm. interpreter.Vm.init + .run() executes the program opcode by opcode against the memory map, charging compute as it goes, dispatching syscall instructions through the registry (which may itself recurse into cpi.zig for a cross-program invocation) until the program hits EXIT or the VM traps.
  7. Settle CU and deserialize. On success, residual metered instruction count is folded into the InvokeContext's compute meter and the exact consumed CU is read back; serialize.deserializeReturn decodes the post-execution input region back into typed account outputs, which are diffed against the pre-state to produce the AccountMutation list returned to the caller.

Two adapter seams make this Bank-free:

  • Sysvars are a plain SysvarCache struct the caller populates — zbpf ships populateTestnetDefaults() for sane Clock/Rent/EpochSchedule/ etc. defaults; there is no dependency on a live Bank to source them.
  • Native-program CPI goes through the NativeProgramResolver vtable described above, not a hard-wired builtins dispatch — the coupling that exists in the validator's own cpi.zig (which always wants the real native programs) is broken here on purpose.

The zolcrypt dependency

syscalls.zig and crypto_helpers.zig import a module named vex_crypto for the hashing/curve syscalls (sol_sha256, sol_keccak256, curve group ops, sol_poseidon, sol_secp256k1_recover, etc.). Rather than fork syscalls.zig to rename that import, zbpf satisfies it with a small shim, src/crypto/compat.zig, that re-exports zolcrypt's modules under the vex_crypto name syscalls.zig expects — this keeps syscalls.zig byte-identical to the in-tree validator copy, which matters for the drift-mitigation story in Scope and status. See build.zig for the exact module wiring (zolcrypt dependency → crypto_shim module → vex_crypto import name → zbpf module).

Module inventory

All vendored VM modules live under src/vm/; every one carries a top-of-file doc comment naming its canonical upstream reference (solana-sbpf / agave source file and line ranges). Package-owned (non-vendored) files are marked accordingly.

File LOC Role
src/vm/syscalls.zig 2,164 The SyscallRegistry — ~42 sol_* syscall handlers (logging, memcpy family, hashing, curve/BN254/Poseidon crypto, sysvar getters, CPI entry points), version-gated per sBPF v0–v3.
src/vm/cpi.zig 2,125 Cross-program invocation: instruction translation, account-permission checks, InvokeContext push/pop, native-vs-BPF routing, writeback of CPI results into the caller's memory.
src/vm/interpreter.zig 1,650 The Vm type — the per-opcode interpreter for sBPF v0–v3, the compute-metered execution loop.
src/vm/elf.zig 1,479 ELF loader and sBPF-version detector; spec-for-spec rebuild of solana-sbpf elf.rs/elf_parser/.
src/vm/verifier.zig 857 Standalone bytecode verifier — opcode legality, branch-target bounds, register/frame-pointer discipline.
src/vm/serialize.zig 819 The ABIv1 input-region byte layout (aligned/unaligned, direct-mapping-aware); byte-identical to the production serializer.
src/vm/invoke_ctx.zig 684 InvokeContext / TransactionContext / InstructionStack — the compute meter, CPI depth tracking, per-account borrow checks, all SIMD feature-gate booleans.
src/vm/memory.zig 657 Region / AlignedMemoryMap — the sBPF virtual memory map (gapped stack, MM_* fixed virtual addresses).
src/vm/sysvar_cache.zig 590 SysvarCache — materialized Clock/Rent/EpochSchedule/EpochRewards/etc. sysvar bytes + typed views; also defines the package's local Pubkey32.
src/session.zig 611 Package-owned. The Session/run orchestrator described in Architecture — a from-scratch re-derivation of the validator's Bank-free dispatch stages, using only zbpf-local types.
src/vm/trace.zig 501 Level-filtered execution trace emitter + ring buffer; also the sink sol_log_* syscalls write through (captured by Session into RunResult.logs).
src/vm/crypto_helpers.zig 348 Shared primitives for the curve-syscall family (Edwards25519/Ristretto255 via Zig stdlib; BN254/Poseidon/BLS12-381 delegate to zolcrypt).
src/vm/v2_program_cache.zig 238 The pubkey-keyed executable cache Session owns — avoids re-parsing/re-verifying an ELF already seen this session.
src/vm/heap_trace.zig 267 Optional heap read/write tracing instrumentation (diagnostic, not required for correctness).
src/vm/native_resolver.zig 156 Package-owned. The NativeProgramResolver vtable + default no-native-programs resolver described above.
src/vm/builtins/ ~9k Builtins-bridge phase, vendored. Vexor's native builtin programs (System/Vote/Stake/Config/ComputeBudget/ALT/ZkElGamalProof/FeatureGate) + their test_harness.zig; mod.zig is the isBuiltin/dispatch registry the bridge wraps.
src/vm/zksdk/ ~7.8k Builtins-bridge phase, native re-implementation. The pure-Zig ZK-ElGamal proof layer (ElGamal/Pedersen/range + sigma proofs) backing the ZkElGamalProof builtin, written from scratch against Sig/Agave as reference oracles (no Sig code, no vex_crypto edge); see NOTICE.
src/vm/builtins_resolver.zig 100 Package-owned (builtins-bridge phase). Wraps builtins/mod.zig as a concrete NativeProgramResolver — the zbpf.builtinsResolver() real bridge.
src/vm/dispatch_mode.zig 113 Small runtime v1/v2/shadow mode flag inherited from the validator; largely a no-op for zbpf embedders (always .v2 during a Session.run).
src/crypto/bls12_381_syscall.zig 122 blst-FFI-linked implementation of the BLS12-381 syscall surface, KAT-verified against the official solana-bls12-381-syscall v0.1.0 vectors — see Scope and status.
src/vm/interp_breadcrumb.zig 53 Per-instruction breadcrumb state for interpreter panic localization; libc-free by design so it doesn't drag libc into test builds.
src/vm/stake_bpf_flag.zig 53 Inherited env-gated flag for the Stake-program dual-path switch; inert unless a caller sets VEX_STAKE_BPF and wires a Stake CPI path (neither exists yet).
src/crypto/compat.zig 33 Package-owned. The vex_crypto-shaped shim over zolcrypt described above.
src/root.zig 60 Package-owned. Public package root — re-exports only, no logic.
src/test_hello_kat.zig 67 Package-owned. The initial hello known-answer test (see below).
src/test_counter_kat.zig 92 Package-owned. CPI-resolver-seam-phase multi-account KAT (counter_zig.so: two accounts read+written, byte-exact mutations, stable CU).
src/test_cpi_resolver_kat.zig 129 Package-owned. CPI-resolver-seam-phase NativeProgramResolver seam KAT (mock-dispatched sol_invoke_signed CPI + default-resolver contract).
src/test_programs.zig Package-owned. Behavioral KATs for the adversarial fixtures: compute scaling, budget exhaustion, OOB memory fault, multi-log ordering, non-zero return propagation.
src/test_cu_regression.zig Package-owned. The exact-CU snapshot table (one row per fixture/input) — the drift guard. See TESTING.md.
src/test_malformed_elf.zig Package-owned. Corrupted-ELF rejection KATs (truncated / bad class / bad machine / bad entrypoint / OOB relocation → clean reject, no host crash).
src/test_determinism.zig Package-owned. Repeatability KATs — identical inputs yield a byte-identical RunResult + CU across 5 runs.

The adversarial fixtures exercised above are compiled sBPF .so files under tests/bpf_fixtures/; their Zig source is under tests/fixtures_src/<name>/ and is rebuilt with the solana-zig toolchain (see BUILDING.md).

Total: ~13,900 lines of Zig across src/, of which the vendored VM core (src/vm/, excluding the two package-owned files native_resolver.zig and the trace/cache/flag utility files) accounts for the large majority. Every module lists its own KAT/unit tests inline as test "..." blocks (elf.zig, verifier.zig, trace.zig, dispatch_mode.zig, crypto_helpers.zig, stake_bpf_flag.zig) that Zig's test collector pulls in transitively through the zbpf module graph — running zig build test therefore exercises all of them, not just the top-level KAT (see Build and test).

Scope and status

zbpf is currently at the "real builtins bridge + independent cross-run parity + anti-drift CI" phase. That phase builds on an earlier "CPI resolver seam + feature-set surfacing + multi-account CU parity" phase, which itself built on the initial "load, verify, and run a real .so standalone" phase. This section is a deliberately honest account of what that does and does not mean; the per-phase deltas live in CHANGELOG.md, and the "What the CPI-resolver-seam phase closed" list below is retained for context. The additions from the current phase — the real builtinsResolver(), the test-builtins-parity cross-run, the BLS/zk scope split, and the anti-drift CI — are marked inline in the deferred list and the Roadmap.

What the CPI-resolver-seam phase closed (all proven by in-repo gate tests, zig build test → 221/221 tests across 7 targets):

  • CPI native-program resolver, threaded end-to-end. The NativeProgramResolver vtable is now carried through InvokeContext (native_resolver_ctx / native_resolver_vtable), and cpi.zig's native-vs-BPF routing consults the caller-supplied resolver instead of a hardwired builtins module. Embedders set RunOptions.native_resolver; the default stays the no-native-programs resolver (the initial-phase behavior). The gate (src/test_cpi_resolver_kat.zig) drives a real sol_invoke_signed CPI (fixture cpi_invoke_zig.so) into a synthetic native id: under a mock resolver the CPI is routed and dispatched (r0 = 0, dispatch counter = 1); under the default resolver that same id is reported as not-a-builtin.
  • Feature set surfaced as a runtime config. The initial phase's compile-time SIMD_PORT_FORCE_OFF_* constants are replaced by zbpf.FeatureSet on RunOptions. The default (.{}) is the initial pre-activation snapshot, so existing behavior/CU are byte-identical; an embedder can now flip individual gates toward live-cluster posture.
  • Multi-account CU-parity fixture. src/test_counter_kat.zig runs counter_zig.so (a solana-zig program that reads AND writes two accounts, each an 8-byte LE u64), asserting byte-exact mutations (account[0] += 1, account[1] += ix_data[0]) and a stable consumed_cu == 153. Because zbpf's engine is byte-copied from the validator's vex_bpf2, that number is the same the in-tree program_test.zig harness reports for identical inputs (see the parity note under Roadmap).

What the CPI-resolver-seam phase deliberately does not cover (deferred to the builtins-bridge phase):

  • No real native-builtins bridge. (Closed in the builtins-bridge phase.) Vexor's builtins/ layer is now vendored and wrapped as zbpf.builtinsResolver(); a CPI into a real System / Vote / Stake / Config / ComputeBudget / ALT / ZkElGamal / Feature Gate program dispatches to the real handler. SPL Token is not a builtin in Agave (it runs as on-chain BPF) and so has no builtin handler here either — by design.
  • Single instruction only. No multi-instruction transaction support, no fee/rent collection, no transaction-wide sequencing — one instruction against one set of account snapshots per call.
  • Feature-set default is still pre-activation. The FeatureSet default is the frozen initial-phase snapshot. Non-default postures are wired through but not yet KAT'd against the live cluster; treat activated gates as embedder-driven, not parity-guaranteed, until the builtins-bridge phase.
  • BLS12-381 is implemented, not a stub. (Closed in the conformance-grind phase.) src/crypto/bls12_381_syscall.zig links the blst C library via FFI (vendored at vendor/blst/, see NOTICE) — the same production code path the live Vexor validator runs — and is verified against the official solana-bls12-381-syscall v0.1.0 known-answer vectors (14/14) plus in-repo regression KATs (test-bls). What is still off by default is the enable_bls12_381_syscall feature gate: it defaults false in zbpf's feature-set snapshot, so a program can't reach the syscall unless an embedder flips it — but the implementation underneath is real, not a placeholder. See CHANGELOG.
  • ZkElGamal, similarly, is shipped. The full zksdk/ proof layer is a native re-implementation (see NOTICE) and the ZkElGamalProof builtin is live in the default build (its range/sigma-proof KATs run under test-builtins-bridge; feature-gate short-circuit behavior under test-zk-elgamal). There is no build-size opt-out today — build.zig exposes no -D options — trimming zksdk/ out of the build for size-sensitive embedders who never touch confidential transfers is an unimplemented idea, not a documented flag.
  • Fifteen test targets, not a full per-module breakdown. zig build test builds all fifteen (test-hello through test-bls; see TESTING.md for the full list); each pulls in every test block reachable through zbpf's import graph (1065 tests total as of this writing), but there is not yet a per-module test-target matrix. See TESTING.md for the full breakdown and the CU-regression deliberate-update-vs-regression workflow.

Vendoring model — and its drift risk. zbpf's VM modules are a copy-out extraction, not a live shared dependency: the code under src/vm/ was copied out of the Vexor validator's vex_bpf2 module and re-homed here with package-owned packaging around it (this Session/run API, the NativeProgramResolver, this build graph). It is not symlinked, git-subtree'd, or otherwise wired so that a fix landing in the validator's copy automatically appears here, or vice versa. Concretely, that means:

  • Every future validator-side VM fix (a CU-metering correction, a verifier edge case, a new SIMD activation) must be manually back-ported into zbpf to keep its "byte/CU-exact vs Agave" claim current. Nothing currently enforces the two trees stay in sync.
  • Symmetrically, a bug zbpf surfaces (via fuzzing or SDK-conformance failures) must be hand-ported back into the validator's vex_bpf2 — the same "check first, then port" discipline the Vexor project already applies to upstream Agave/Firedancer references.
  • Mitigation (planned, not yet built): a scheduled parity-diff CI job that re-runs zbpf's fixture set against whatever the validator's VM HEAD currently reports, so divergence is caught on a cadence rather than discovered by surprise. This is a builtins-bridge-phase item — see Roadmap.

If you need day-of live-testnet feature currency rather than a fixed, reproducible initial-phase snapshot, track the roadmap below or watch for the scheduled-parity-CI signal before relying on zbpf for anything consensus-adjacent.

Roadmap

CPI resolver seam + feature-set surfacing + multi-account CU parity. ✅ DONE (see Scope and status).

  • ✅ Threaded a caller-supplied NativeProgramResolver through InvokeContext so cpi.zig's native-vs-BPF routing calls the vtable instead of a hardwired module; proven with a mock resolver driving a real sol_invoke_signed CPI.

  • ✅ Added a multi-account fixture (counter_zig.so, reads+writes two accounts) with byte-exact mutation assertions and a stable checked-in CU.

  • ✅ Replaced the compile-time SIMD_PORT_FORCE_OFF_* constants with a runtime FeatureSet config on RunOptions (default = initial-phase pre-activation snapshot).

    Parity-proof method (and its caveat at the time). The CU numbers zbpf asserts (105, 153) are the engine's own output, and zbpf's engine is a byte-for-byte copy of the validator's vex_bpf2, so by construction they equal what the in-tree program_test.zig harness reports for identical inputs — that harness is "pure glue" over the same v2DispatchBpfProgramMetered. A fully-independent cross-run (feeding the same .so + account snapshots through the in-tree CLI and diffing) is the confirmation the next phase provides; at this point the CLI (vexor-program-test) does not yet accept custom multi-account snapshots on its command line, so this phase asserts internal consistency + documents the method rather than performing the cross-run.

Real builtins bridge, deeper parity, polish, docs, and anti-drift CI. ✅ DONE (see CHANGELOG).

  • ✅ Shipped Vexor's builtins/ layer (System/Vote/Stake/Config/ComputeBudget/ ALT/ZkElGamal/FeatureGate) behind the CPI-resolver-seam phase's NativeProgramResolver as zbpf.builtinsResolver(), so a CPI into a real native program dispatches correctly — replacing the earlier mock-only proof. The no-native defaultResolver() stays the null-default; the real builtins are opt-in.

  • ✅ Extended the parity harness (test-builtins-parity) to diff CU + post-state byte-for-byte across multi-account snapshots against the frozen in-tree-engine reference (the independent cross-run the earlier phase's note deferred), and exercised the real System/ComputeBudget handlers' state/CU through test-builtins-bridge.

  • ✅ Per-module API documentation (doc comments on public decls) and this CHANGELOG.md, recording the Agave-4.2 CU-parity provenance per release.

  • ✅ A scheduled parity-diff CI job (.github/workflows/ci.yml) that re-runs the fixture corpus on a cadence, giving the "copy-out, not shared" vendoring model an automated drift tripwire instead of manual back-porting alone.

  • ✅ CI: zig build test on every push; a cold clone + zig build test succeeding with no manual path setup is the acceptance gate for this phase (demonstrated).

  • Split out (deliberate): folding the now-real, blst-FFI-linked BLS12-381 implementation into zolcrypt as a pure-Zig implementation remains a major standalone crypto initiative (enable_bls12_381_syscall stays off by default in the meantime); a build-size opt-out for embedders who want to exclude zksdk//ZkElGamal entirely is an unimplemented idea — build.zig exposes no -D options today.

Provenance

zbpf was extracted from the Vexor validator's vex_bpf2 module (the validator's development tree at the time of extraction, commit 103ff91) per the design investigation in ZBPF-STANDALONE-VM-EXTRACTION-DESIGN-2026-07-12.md. Full attribution detail, including which files are vendored-verbatim versus package-owned re-derivations, lives in NOTICE; the short version:

  • The VM core under src/vm/ is Vexor-authored Zig, written using the projects below as reference implementations and differential test oracles — their source was read and their behavior reimplemented in Zig, then verified bit-for-bit against published vectors and live-cluster behavior. zbpf links none of their code:
    • Agave (Apache-2.0) — the consensus ground truth for serialization, CU metering, CPI semantics, and the loader/verifier byte contract. Version pin: 4.2.0-beta.0 for the CU-parity audit; individual module doc comments cite the specific solana-sbpf/agave commit and file:line ranges they were built against.
    • Firedancer (Apache-2.0) — differential oracle for the VM (fd_vm) and BPF-loader serialization.
    • anza-xyz/sbpf and solana-sbpf (Apache-2.0) — the sBPF ISA, memory-map layout, and verifier rules reference; the primary canonical reference cited in elf.zig, memory.zig, verifier.zig, and interpreter.zig's doc comments.
    • Syndica's Sig (Apache-2.0) — a Zig-idiom shape reference for the VM and memory map (non-authoritative; used for style, not semantics), and, separately, the reference implementation and differential test oracle for src/vm/zksdk/ — that layer is a from-scratch native-Zig re-implementation, not a copy of Sig's code, and carries no Sig copyright.
  • Vendored third-party code (actual linked source, not a reference): vendor/blst/Supranational's blst (Apache-2.0), the BLS12-381 pairing library src/crypto/bls12_381_syscall.zig links via FFI. See vendor/blst/LICENSE.
  • Package-owned code, not extracted verbatim:
    • src/session.zig — a from-scratch re-derivation of the validator's Bank-free dispatch orchestrator (v2_dispatch.v2DispatchBpfProgramMetered), rewritten against package-local value types with all Bank/AccountsDb/replay coupling and diagnostic probes removed.
    • src/vm/native_resolver.zig — the pluggable NativeProgramResolver interface that replaces the validator's hard-wired builtins/ dispatch in cpi.zig.
    • src/root.zig, src/crypto/compat.zig — package root and the zolcrypt compat shim.
  • @prov: anchors. Several vendored files (serialize.zig, syscalls.zig, cpi.zig, sysvar_cache.zig) carry inline @prov: comment tags pointing to a fuller upstream line-map (PROVENANCE.md in the source tree they were extracted from) — these tags are preserved as-is in the extraction for traceability, even though a standalone PROVENANCE.md has not yet been re-published in this repository.

"Agave", "Firedancer", "Sig", and "Solana" are trademarks of their respective owners; see NOTICE for the full Apache-2.0-spirit attribution statement.

Build and test

Requires Zig 0.15.2 (minimum_zig_version in build.zig.zon). Full detail lives in two dedicated guides:

  • BUILDING.md — the host build, core-pinning, and how to rebuild the sBPF fixtures with the solana-zig toolchain.
  • TESTING.md — every test target, the fixture inventory, the CU-regression table, and the deliberate-update-vs-regression workflow.
zig build test     # build + run all 15 test targets (see TESTING.md)
zig build          # builds the static zbpf library (compile-checks the whole VM graph)
zig fmt --check .   # formatting gate

On a shared machine (or any box also running a live validator), core-pin the build/test invocation so it doesn't contend with production workloads — this is a standing rule, applied even when the validator is down:

nice -n 19 ionice -c3 taskset -c 28-31 zig build test

As of this writing, at the current (conformance-grind) phase, zig build test reports 1065/1065 tests passed across 15 targets:

Build Summary: 31/31 steps succeeded; 1065/1065 tests passed
[ZBPF-HELLO-KAT]       sbpf=v0 err=null r0=0 consumed_cu=105 logs=1 muts=0
[ZBPF-COUNTER-KAT]     sbpf=v0 err=null r0=0 consumed_cu=153 logs=1 muts=2
[ZBPF-CPI-MOCK]        err=null r0=0 cu=1122 logs=1 dispatched=1
[ZBPF-BUILTINS-CB]     charged_cu=150         (real ComputeBudget handler)
[ZBPF-BUILTINS-CB-CPI] err=null r0=0 consumed_cu=1272 (real handler via cpi.zig)
[ZBPF-BUILTINS-SYS]    from=70 to=30          (real System Transfer)
[ZBPF-PARITY counter_zig] consumed_cu=153 — MATCH (full post-state + CU)

The fifteen gate targets:

Target Gates
test-hello Initial-phase hello KAT (CU==105).
test-counter CPI-resolver-seam-phase multi-account read/write + stable CU==153.
test-cpi-resolver CPI-resolver-seam-phase NativeProgramResolver seam: mock-dispatched CPI + default-resolver contract.
test-programs Behavioral KATs for the adversarial fixtures.
test-cu-regression The exact-CU snapshot table (drift guard).
test-malformed-elf Corrupted-ELF images all rejected cleanly, no host crash.
test-determinism Identical inputs → byte-identical result + CU across 5 runs.
test-builtins-bridge Builtins-bridge phase: real System/ComputeBudget through builtinsResolver() (incl. the full cpi.zig path) + the builtins/zksdk layer's own ~232 inline KATs + builtinsSelfTest().
test-builtins-parity Builtins-bridge phase: independent cross-run — full post-state + exact CU diffed byte-for-byte vs the in-tree-engine reference (counter_zig, arith_zig, compute_loop_zig).
test-precompiles Ed25519/secp256k1/secp256r1 precompile dispatch, against the real vex_crypto-identical verify functions.
test-memory Gapped Region.translate multi-frame-crossing access matches Agave/FD canonical bounds.
test-syscalls sol_try_find_program_address output-overlap check, MAX_SEEDS off-by-one, sol_log_ UTF-8 validation.
test-vote Vote-program InitializeAccount/Withdraw/AuthorizeWithSeed account-count and ordering edge cases.
test-zk-elgamal disable_zk_elgamal_proof_program / reenable_zk_elgamal_proof_program feature-gate short-circuit, zero CU, before parsing.
test-bls BLS12-381 pairing-map KATs (G2-at-infinity, mixed real/infinity, all-real bilinearity regression).

CU-regression table (anchors)

Every row is asserted exactly by test-cu-regression; any drift trips exactly one row. Fault/revert rows report the full budget (1_400_000) by validator convention.

Program (inputs) Expected CU
hello_zig 105
counter_zig (ix=[9]) 153
cpi_invoke_zig (mock resolver) 1122
arith_zig (seed u64=5) 163
compute_loop_zig @ n=100 / 200 / 300 734 / 1434 / 2134
multi_log_zig 414
return_nonzero_zig (r0=42 revert) 1 400 000
cu_exhaust_zig (OutOfCompute) 1 400 000
oob_zig (AccessViolation) 1 400 000

Malformed-ELF coverage

test-malformed-elf corrupts one field of hello_zig.so at a time and proves each image is rejected cleanly (a RunResult with dispatch_error set — never a host crash): truncated-below-header, truncated-mid-image, bad identity header (ei_class), bad e_machine, out-of-range e_entry, and an out-of-bounds relocation r_offset (whose bounds check fires before the write, so there is no wild store). See TESTING.md for the faithful-to-validator note on why the v0 lenient loader validates identity via ei_class rather than the 4-byte magic signature.

License

Apache-2.0. See LICENSE for the full license text and NOTICE for provenance and third-party attribution detail.


Apache-2.0. Part of the Vexor ecosystem — vexornode.xyz.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages