Skip to main content
Canonical target id: solana-sbpf-asm This is a new route distinct from the existing solana-sbpf-linker (Zig) route. It generates sBPF assembly text (.s) directly from the portable contract IR, then delegates to the blueshift-gg/sbpf toolchain for assembly, linking, and packaging into a Solana loader-compatible ELF.

Route Rationale

Why this route over the Zig/sbpf-linker route

Cost: ProofForge must implement a full lowering backend in Lean that emits sBPF assembly. This includes register allocation, stack-frame discipline, Solana account ABI parsing, instruction dispatch, and Borsh-like serialization.

Target Toolchain: blueshift-gg/sbpf

The sbpf CLI provides everything needed after .s emission: ProofForge’s role is to produce valid .s text files that sbpf build accepts. No further toolchain work is needed in this repo.

SDK Reference Anchors

The Solana SDK completion work tracks these upstream surfaces:
  • Solana CPI: native programs call other programs through invoke / invoke_signed, which is the high-level Rust API shape ProofForge lowers to sol_invoke_signed_c.
  • SPL Token: TokenInstruction defines the account schemas and data payloads for transfer_checked, mint_to, burn, approve, revoke, and set_authority.
  • Pinocchio: the framework target is a no_std, zero-copy, no-copy/no-allocation entrypoint style with optional allocator control; ProofForge mirrors that by keeping Solana account parsing, allocator policy, and CPI packing in target lowering rather than portable IR.
  • pinocchio-tkn: the longer-term token SDK reference is stack-only, zero-allocation CPI helpers spanning SPL Token and Token-2022. ProofForge’s current SPL Token helpers are the first compatible slice of that surface.

Assembler ISA

The sBPF assembly grammar (from the blueshift sbpf.pest PEG grammar): Registers: r0r10 (64 bit), w0w10 (32‑bit alias for lo‑half). ALU (64‑bit & 32‑bit): add64/32, sub64/32, mul64/32, div64/32, or64/32, and64/32, lsh64/32, rsh64/32, mod64/32, xor64/32, mov64/32, arsh64/32, neg64/32 — each with immediate and register variants. Endian byte-swap: le16/32/64, be16/32/64. Loads/stores: lddw rD, imm64, ldxb/h/w/dw rD, [rBase ± off], stb/h/w/dw [rBase ± off], imm, stxb/h/w/xdw [rBase ± off], rS. Control flow: ja target, jeq/jne/jgt/jge/jlt/jle/jsgt/jsge/jslt/jsle/jset rA, rB/imm, target, call <syscall>, callx rA, exit. Directives: .globl, .equ, .text / .data / .rodata, .ascii / .byte / .short / .word / .int / .long / .quad. Identifiers: labels (alphanumeric + underscore), numeric labels (0:, 1: with 0f/0b references).

Available syscalls

Syscall coverage plan

ProofForge treats Solana syscalls as target-extension capabilities, not portable IR primitives. Each syscall family should move through the same evidence ladder: SDK/API shape → capability metadata → sBPF AST helper → assembly smoke → sbpf build → Mollusk/runtime test → Surfpool/Web3.js live test when the syscall changes observable chain behavior. Implementation note: sol_get_epoch_schedule_sysvar returns the runtime struct layout, not the compact 33-byte sysvar-account serialization. The live Surfpool/Web3.js gate pins the currently used offsets as slots_per_epoch = 0, leader_schedule_slot_offset = 8, warmup = 16, first_normal_epoch = 24, and first_normal_slot = 32. Implementation note: sol_get_epoch_rewards_sysvar writes the runtime EpochRewards struct. ProofForge exposes 64-bit state views for every field: distribution_starting_block_height = 0, num_partitions = 8, parent_blockhash_word0..3 = 16,24,32,40, total_points_low/high = 48,56, total_rewards = 64, distributed_rewards = 72, and active = 80.

Runtime allocator

Solana’s Rust SDK entrypoint installs a default heap allocator. The runtime constants are HEAP_START_ADDRESS = 0x300000000 and HEAP_LENGTH = 32 * 1024, and the allocator is a one-way bump allocator: alloc moves the bump pointer downward with alignment and dealloc is a no-op. Pinocchio follows the same shape: entrypoint! expands to the program entrypoint plus default_allocator! and default_panic_handler!; lower-level macros also let a program opt out with no_allocator!. ProofForge mirrors this at the target-extension layer instead of baking it into portable IR:
bumpAllocator records runtime.allocator with:
noAllocator records kind = "none" and model = "deny-dynamic", matching the no-heap pattern useful for Pinocchio-style programs that intentionally avoid dynamic allocation. At this stage the selected allocator is emitted in manifest.toml, proof-forge-artifact.json, and assembly metadata comments. Future lowering for heap-backed SDK data structures must route through this capability before emitting real allocation code.

Solana Contract Model

Solana programs have a single entrypoint:
The input buffer (r1) contains a serialized layout:
The entrypoint parses this, dispatches on the first byte of instruction data (instruction discriminant), validates accounts, and mutates account data.

Instruction Manifest

Solana requires explicit account schemas — a sidecar manifest describing instruction dispatch and account constraints. Below is the proposed TOML format (should live as target metadata, not embedded into the generic Lean source).
This manifest drives:
  • Offsets for each account field in the generated .s (computed by the codegen from the account count and data lengths).
  • Validation code emitted in the entrypoint adapter (signer, writable, owner checks).
  • Instruction dispatch (first‑byte discriminant → handler label).
  • Test input generation (the input.json fed to sbpf debug or test runner).

IR Lowering Design

The lowering lives in ProofForge/Backend/Solana/SbpfAsm.lean and consumes a ProofForge.IR.Contract.Module to produce:
  1. An sBPF assembly text file (.s) for each contract module.
  2. An instruction manifest (.toml).
  3. A proof-forge-artifact.json with artifact metadata.

Module structure

Each Lean contract module compiles to:

IR to sBPF lowering walkthrough

For the Counter shared scenario: IR (IR/Contract.lean level):
Lowered sBPF assembly (simplified — StorageBackend = account data at fixed offset):

Register discipline

sBPF has 11 registers (r0–r10). Convention: For the initial spike, a simple convention suffices:
  • r1 = input buffer base (never spilled — needed for all account access).
  • r6 = instruction_data base pointer (computed once from r1).
  • r7–r9 = scratch for intermediate values.
  • Stack (r10 - N) for spilled locals when registers are exhausted.
  • Every entrypoint handler is at most ~70 instructions; simple register reuse is acceptable before implementing a proper register allocator.

Expression lowering rules

Each IR Expr node lowers to sBPF instructions that compute the value into a target register. Example mapping: All 64‑bit computations should be 32‑bit safe (32‑bit variants for .u32, no over-64‑bit intermediate overflows). sol_log_64_ is available for debugging.

Statement lowering rules

Effect lowering: storage

storageScalar / storageArray / storageStructField all map to account data offsets rather than EVM slot storage. The IR state declarations carry the owning account index and field offset, computed by the state-layout compiler pass before codegen. Storage layout is deterministic and computed at codegen time:
  1. For each account declared in the manifest, assign the data region start offset.
  2. For each state variable owned by that account, allocate a fixed offset within the data region, packing fields (u64 aligned).
  3. Emit .equ constants for every field offset so the assembly is readable.

Effect lowering: CPI/PDA (Solana-specific SDK extension)

CPI and PDA derivation are Solana‑only concepts (D-027). They do not enter the portable IR. Instead, Solana-specific SDK calls are routed through ProofForge.Solana into target capability calls, gated by the existing crosscall.cpi and storage.pda capability IDs in Target/Capability.lean:
The portable IR never knows about these — it operates at the capability level via crosscall.cpi and storage.pda. The generic builder records entrypoint scope as proof_forge.entrypoint; the Solana backend resolves that metadata into entrypoint actions and injects helper calls after account validation and before the portable IR body. The generated assembly preserves r1 around helper calls so subsequent storage lowering still sees the original Solana input pointer. Current CPI/PDA lowering pattern:
  1. Allocate stack space for SolInstruction + SolAccountInfo[] + seeds.
  2. Emit one helper per declared PDA/CPI intent (sol_pda_derive_<name>, sol_cpi_<name>).
  3. In entrypoint handlers with scoped SDK actions, call the helper and branch to error_pda / error_cpi when r0 != 0.
  4. Build a module-wide multi-account instruction schema from state, PDA, CPI accounts, and executable CPI program accounts. This schema is used by manifest.toml, proof-forge-artifact.json, fixed instruction-data offset computation, and generated signer/writable/program-owner validation.
  5. Build manifest.toml and artifact metadata with both extension definitions and entrypoint action lists.
The SDK layer already exposes protocol-level helpers for System Program transfer/create-account and SPL Token transfer/mint/burn/approve/revoke/ set-authority. These helpers emit solana.cpi.protocol, solana.cpi.data_layout, account metas, signer seeds, and instruction-data sources into the capability plan, manifest, and artifact metadata. The source-facing layer exposes first-class contract_source forms for System Program transfer, System Program create_account, and SPL Token transfer_checked plus set_authority. These forms are still a v1 embedded macro frontend rather than the legacy standalone .learn parser, but they prevent new examples from dropping back to raw ContractSpec/builder strings for the core CPI paths. System and SPL Token helpers now emit the C ABI packing skeleton for sol_invoke_signed_c: program id bytes, C SolAccountMeta[], standard instruction-data bytes, C SolInstruction, bound SolAccountInfo[], optional signer seed tables, and the syscall register contract. system.transfer uses the bincode-style u32 discriminator=2 + u64 lamports layout; system.create_account uses u32 discriminator=0 + u64 lamports + u64 space + owner pubkey; SPL Token transfer_checked, mint_to, burn, approve, and revoke use the standard token instruction tags and amount/decimals layouts; SPL Token set_authority uses instruction tag 6, authority type 0 (MintTokens), a Some option byte, and a new-authority pubkey copied from the generated program’s readonly new_authority input account. Program ids, account meta pubkeys, and SolAccountInfo key/lamports/data/owner/rent/flag fields are sourced from the generated multi-account input layout when the account appears in the module schema. CPI value sources can bind to scalar state offsets, numeric literals, or decoded entrypoint parameters. PDA helper metadata now carries both a compatibility seeds list and target-facing typed seed descriptors. Bare strings remain literal seed bytes for backward compatibility; SDK helpers such as literalSeed, utf8Seed, accountSeed, bumpSeed, and paramSeed make the source explicit for Solana lowering. The Solana target extension consumes those descriptors, appends the declared bump? as an effective bump seed, and emits typed_seeds in manifest.toml plus typedSeeds in proof-forge-artifact.json. This remains a target-extension concern: portable IR and the chain-neutral SDK surface only see capability intent, while --target solana-sbpf-asm decides how those capabilities are packed into the Solana syscall ABI. The current instruction-data ABI reserves byte 0 for the ProofForge entrypoint tag. Packed scalar parameters start at instruction_data+1, in entrypoint parameter order, with little-endian U64/U32 loads and one-byte Bool loads. The generated dispatcher rejects empty instruction data before reading the tag; each handler also checks the minimum payload length required by its parameter schema before decoding. The backend decodes those parameters into stack locals before SDK helper calls and exposes the same absolute input offsets to CPI value binding, so helpers can pack fields such as SPL Token amount directly from user instruction data. manifest.toml and proof-forge-artifact.json record each instruction’s min_data_len/minDataLen plus parameter name, type, offset, byte size, and encoding. The module-wide helper table only binds a parameter name when all occurrences share the same offset; duplicate names at conflicting offsets are intentionally left unbound until per-entrypoint helper specialization lands. Remaining work: add dynamic per-entrypoint account parsing, richer aggregate/string/bytes instruction ABI decoding, return-data decoding, and runtime tests that exercise live CPI paths. PDA helper lowering:
  1. Allocate stack space for seed data + result buffer (32 byte).
  2. Pack typed seeds into Solana Slice { ptr, len } entries: literal/UTF-8 seeds are copied into stack buffers, account seeds point at input account pubkeys, bump seeds are one byte, and scalar instruction-data seeds are copied from the decoded fixed input offset.
  3. call sol_create_program_address.
  4. Restore the Solana input pointer and, when account? is declared, compare the 32-byte derived pubkey with the declared account pubkey before returning.

Effect lowering: events

Solana has no chain-level event log like EVM. Options:
  1. sol_log_ / sol_log_64_ — simple but unstructured.
  2. sol_log_data — base64 data logs used as the Anchor-style event payload carrier.
  3. sol_set_return_data as a quasi-event mechanism.
Phase 1 emits scalar eventEmit fields through sol_log_64_ as [eventTag, fieldIndex, value, 0, 0]. The event tag is a stable 32-bit compile-time tag derived from the event name so generated Web3.js harnesses can assert the transaction log without baking in Solana-specific syntax at the portable SDK layer. Solana-only logAccountPubkey lowers account keys through sol_log_pubkey, and logStateData lowers fixed state-backed byte payloads through sol_log_data as the base layer for future Anchor-compatible discriminator/Borsh event serialization. Future work should add string sol_log_ payloads, complete Anchor-compatible serialization, and indexed event forms.

Capability mapping

The target profile must accept or reject each IR capability. The proposed solana-sbpf-asm profile extends the existing solanaSbpfLinker capability set:

CLI and Build Integration

New CLI flag

Also:
  • --emit-sbpf-asm — emit .s without invoking sbpf build (development).
  • --emit-sbpf-elf or --solana-elf — emit .s then invoke sbpf build.
  • --solana-sbpf-arch v0|v3 — pass the selected sbpf architecture to sbpf build --arch; artifacts record the value under toolchain.sbpf.arch.

Build pipeline steps

  1. Lean frontend: Parse contract, resolve LCNF.
  2. IR extraction: Map LCNF to ProofForge.IR.Contract.Module.
  3. Capability check: Validate against solana-sbpf-asm target profile.
  4. Storage layout: Compute account data offsets per manifest, assign .equ constants.
  5. Codegen (ProofForge.Backend.Solana.SbpfAsm):
    • Emit instruction dispatch adapter (labeled handlers).
    • For each entrypoint: lower body statements + expressions → sBPF text.
    • Emit .rodata for string constants, event type tags.
  6. Write .s: Produce src/<module>/<module>.s.
  7. Write manifest.toml: Record instruction metadata, account offsets.
  8. sbpf build: Invoke external tool with the selected sbpf architecture, produce deploy/<module>.so.
  9. Artifact metadata: Write proof-forge-artifact.json recording irVersion, target id, tool versions, capability subset.

Artifact metadata

State Layout: From IR StateDecl to sBPF .equs

The storage layout compiler (ProofForge.Backend.Solana.StateLayout) takes the account manifest + IR state declarations and computes fixed offsets for every state variable per account. Example state declarations:
If account_index maps "counter" → 1 (the Counter account) and the Counter account data region starts at byte 0 within that account’s data buffer: Each .equ constant is added to the per-account data base (COUNTER_DATA in the dispatch adapter), producing the final memory reference: [r1 + COUNTER_DATA + COUNTER_COUNT_OFFSET].

Toolchain Dependency

CI should make Solana tests optional (gated on sbpf, Surfpool, Solana CLI, and Node tooling) following the same pattern as others (solc, foundry, dargo per validation-gates.md).

Test Strategy

Spike 1: Static entrypoint

  • Generated entrypoint returns success (r0 = 0).
  • No account parsing, no storage.
  • sbpf build succeeds, sbpf debug shows the entrypoint executes.
  • Validate the .s round‑trips via sbpf disassemble.

Spike 2: Counter (single scalar u64)

  • Account manifest with one writable account.
  • Dispatch adapter: parse accounts, validate signer, dispatch on instruction tag.
  • initialize: write u64(0) to account data at fixed offset.
  • increment: read, add 1, write.
  • sbpf test with Mollusk.
  • Surfpool/Web3.js live deploy/invoke smoke.

Spike 3: Multiple instruction types, typed returns

  • Add return data (sol_set_return_data).
  • Multi‑instruction dispatch.
  • Validation gate against the shared Counter scenario.

Spike 4: CPI (System Program)

  • Account creation via System Program CPI with signer seeds.
  • PDA derivation and validation.
  • sol_invoke_signed_c call pattern.

Spike 5: Borsh and structured types

  • Struct state with multiple fields.
  • Borsh serialization/deserialization primitives (hand‑written sBPF or generated).
  • Map storage via sorted entries or sparse buckets.

Spike 6: SPL Token CPI

  • Token account create, mint, transfer.
  • Associated Token Program integration.

Acceptance criteria

Lean Module Layout

Input/Abstraction: The Lean SDK Layer

Above the sBPF codegen, developers should get convenient Lean abstractions. Proposed API (Phase 3):
The sBPF codegen recognizes these opaque operations and lowers them directly to account offset loads / syscalls — they never exist as Lean runtime code.

Registration in Target Profile

New target profile in ProofForge/Target/Registry.lean:

Risks and Mitigations

Phased Implementation Plan

Phase 0: Toolchain integration (Spike 1)

  • Add solana-sbpf-asm to Target/Registry.lean.
  • Write a fixed sBPF entrypoint .s that returns success.
  • Run sbpf build + sbpf debug round‑trip.
  • CLI flag --emit-sbpf-asm that writes the canned .s.

Phase 1: Counter (Spike 2–3)

  • StateLayout.lean: account offsets from manifest.
  • SbpfAsm.lean: lowering for Module → .s text.
  • Support: literal, local, add, effect(storageScalarWrite/Read), letBind, assign, ifElse, return, assert.
  • Counter scenario passes sbpf test.

Phase 2: Storage mid‑level (Spike 4-5)

  • Storage arrays, structs, and maps.
  • CPI (Account creation, SPL Token transfers).
  • PDA derivation.
  • Bounded loops.
  • Instruction manifest TOML generation.

Phase 3: Developer SDK

Phase 3 is split into verifiable SDK completeness levels rather than one large “framework” milestone. Estimates assume one engineer working from the 2026-07-02 baseline, current direct-assembly codegen staying stable, and local sbpf/Surfpool/Solana CLI tooling being available. The alpha line is the point where a developer should be able to write and deploy simple Solana programs without hand-written assembly patches. The beta line is the point where ProofForge output can be compared against reference Rust/Pinocchio programs for the same account schema. The System transfer, System create_account, SPL Token transfer_checked, and SPL Token mint_to/burn/approve/revoke plus SPL Token set_authority reference contracts are the first static equivalence anchors for that line, and their live dual-deploy harnesses are already wired to build/deploy both ELFs when Solana rustc is available. The final framework line adds the higher-level ergonomics expected from Anchor-like and Pinocchio-style workflows without moving Solana-specific details into portable IR.

References