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.
- Why zbpf exists
- Who it's for
- The headline feature: cluster-exact CU accounting
- Quickstart
- The public API
- Architecture
- Module inventory
- Scope and status
- Roadmap
- Provenance
- Build and test
- License
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.
- Fuzzers targeting sBPF opcode semantics, the ELF/verifier pass, or the
input-region serializer:
zbpfgives 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:
zbpfis a second, independent execution oracle a Zig SDK can diff its own local-execution results against, distinct from the validator itself.
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).
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.gzzbpf 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"));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.
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 ...
}Everything below is re-exported at the package root (@import("zbpf")); see
src/root.zig for the authoritative export list.
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.
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.
| 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. |
| 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).
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
};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
});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.
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.
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:
- Resolve + cache the executable. On a cache miss,
elf.Executable.loadparses the ELF andverifier.verifyruns the bytecode verifier over it (opcode legality, jump-target bounds, register discipline); the result is stored in the session'sV2ProgramCachekeyed by program pubkey so a second call with the same program id skips both passes. - Serialize the input region.
serialize.serializeParametersAlignedlays 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). - 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). - Build the syscall registry.
syscalls.SyscallRegistry.initbuilds a version-gated table of the ~42sol_*syscalls the interpreter can call into (logging, memory ops, hashing, curve/crypto ops, sysvar reads, CPI entry points, …). - 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. - Run the
Vm.interpreter.Vm.init+.run()executes the program opcode by opcode against the memory map, charging compute as it goes, dispatchingsyscallinstructions through the registry (which may itself recurse intocpi.zigfor a cross-program invocation) until the program hitsEXITor the VM traps. - 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.deserializeReturndecodes the post-execution input region back into typed account outputs, which are diffed against the pre-state to produce theAccountMutationlist returned to the caller.
Two adapter seams make this Bank-free:
- Sysvars are a plain
SysvarCachestruct the caller populates —zbpfshipspopulateTestnetDefaults()for sane Clock/Rent/EpochSchedule/ etc. defaults; there is no dependency on a liveBankto source them. - Native-program CPI goes through the
NativeProgramResolvervtable described above, not a hard-wiredbuiltinsdispatch — the coupling that exists in the validator's owncpi.zig(which always wants the real native programs) is broken here on purpose.
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).
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).
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
NativeProgramResolvervtable is now carried throughInvokeContext(native_resolver_ctx/native_resolver_vtable), andcpi.zig's native-vs-BPF routing consults the caller-supplied resolver instead of a hardwired builtins module. Embedders setRunOptions.native_resolver; the default stays the no-native-programs resolver (the initial-phase behavior). The gate (src/test_cpi_resolver_kat.zig) drives a realsol_invoke_signedCPI (fixturecpi_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 byzbpf.FeatureSetonRunOptions. 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.zigrunscounter_zig.so(asolana-zigprogram 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 stableconsumed_cu == 153. Because zbpf's engine is byte-copied from the validator'svex_bpf2, that number is the same the in-treeprogram_test.zigharness 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 aszbpf.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
FeatureSetdefault 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.ziglinks theblstC library via FFI (vendored atvendor/blst/, see NOTICE) — the same production code path the live Vexor validator runs — and is verified against the officialsolana-bls12-381-syscallv0.1.0 known-answer vectors (14/14) plus in-repo regression KATs (test-bls). What is still off by default is theenable_bls12_381_syscallfeature gate: it defaultsfalsein 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 undertest-builtins-bridge; feature-gate short-circuit behavior undertest-zk-elgamal). There is no build-size opt-out today —build.zigexposes no-Doptions — trimmingzksdk/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 testbuilds all fifteen (test-hellothroughtest-bls; see TESTING.md for the full list); each pulls in everytestblock reachable throughzbpf'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
zbpfto keep its "byte/CU-exact vs Agave" claim current. Nothing currently enforces the two trees stay in sync. - Symmetrically, a bug
zbpfsurfaces (via fuzzing or SDK-conformance failures) must be hand-ported back into the validator'svex_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.
CPI resolver seam + feature-set surfacing + multi-account CU parity. ✅ DONE (see Scope and status).
-
✅ Threaded a caller-supplied
NativeProgramResolverthroughInvokeContextsocpi.zig's native-vs-BPF routing calls the vtable instead of a hardwired module; proven with a mock resolver driving a realsol_invoke_signedCPI. -
✅ 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 runtimeFeatureSetconfig onRunOptions(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'svex_bpf2, so by construction they equal what the in-treeprogram_test.zigharness reports for identical inputs — that harness is "pure glue" over the samev2DispatchBpfProgramMetered. 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'sNativeProgramResolveraszbpf.builtinsResolver(), so a CPI into a real native program dispatches correctly — replacing the earlier mock-only proof. The no-nativedefaultResolver()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 throughtest-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 teston every push; a cold clone +zig build testsucceeding 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 intozolcryptas a pure-Zig implementation remains a major standalone crypto initiative (enable_bls12_381_syscallstays off by default in the meantime); a build-size opt-out for embedders who want to excludezksdk//ZkElGamal entirely is an unimplemented idea —build.zigexposes no-Doptions today.
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.zbpflinks 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.0for the CU-parity audit; individual module doc comments cite the specificsolana-sbpf/agavecommit 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, andinterpreter.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.
- Agave (Apache-2.0) — the
consensus ground truth for serialization, CU metering, CPI semantics,
and the loader/verifier byte contract. Version pin:
- Vendored third-party code (actual linked source, not a reference):
vendor/blst/— Supranational's blst (Apache-2.0), the BLS12-381 pairing librarysrc/crypto/bls12_381_syscall.ziglinks via FFI. Seevendor/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 allBank/AccountsDb/replay coupling and diagnostic probes removed.src/vm/native_resolver.zig— the pluggableNativeProgramResolverinterface that replaces the validator's hard-wiredbuiltins/dispatch incpi.zig.src/root.zig,src/crypto/compat.zig— package root and thezolcryptcompat 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.mdin the source tree they were extracted from) — these tags are preserved as-is in the extraction for traceability, even though a standalonePROVENANCE.mdhas 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.
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-zigtoolchain. - 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 gateOn 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 testAs 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). |
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 |
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.
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.