Tenzro Testnet is live. Get testnet TNZO

SVM Executor

The Tenzro SVM executor provides Solana Virtual Machine compatibility using the solana_rbpf library for BPF (Berkeley Packet Filter) program execution. It implements Solana's account model, syscall interface, compute budget metering, and program deployment mechanics.

Architecture

The SvmExecutor wraps solana_rbpf to execute eBPF bytecode compiled from Rust programs. Unlike the EVM's stack-based architecture, SVM uses a register-based virtual machine with explicit account passing and copy-on-write semantics.

┌────────────────────────────────┐
       SvmExecutor              
   (Solana Compatibility)       
└───────────┬────────────────────┘
            
             wraps
            
┌────────────────────────────────┐
      solana_rbpf               
   (eBPF VM Implementation)     
├────────────────────────────────┤
  Register-based execution     
  BPF bytecode interpreter     
  Compute unit metering        
  Syscall interface            
└───────────┬────────────────────┘
            
             syscalls
            
┌────────────────────────────────┐
     Syscall Registry           
  (sol_log, sol_sha256, etc.)   
└───────────┬────────────────────┘
            
             state access
            
┌────────────────────────────────┐
     Account Storage            
   (Solana Account Model)       
└────────────────────────────────┘

Program Execution

SVM programs are executed by loading eBPF bytecode into the VM, serializing account data according to Solana's format, and invoking the program entry point. The VM executes instructions while metering compute units consumed.

Basic Program Invocation

use tenzro_vm::svm::SvmExecutor;
use tenzro_types::{Transaction, Account};

let executor = SvmExecutor::new(state_adapter);

// Load program from storage
let program_address = Address::from_base58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")?;
let program_bytecode = state_adapter.get_code(&program_address)?;

// Prepare accounts for instruction
let accounts = vec![
    Account {
        address: token_mint_address,
        lamports: 1_000_000,
        data: mint_account_data,
        owner: token_program_id,
        is_signer: false,
        is_writable: true,
    },
    Account {
        address: user_token_account,
        lamports: 2_000_000,
        data: token_account_data,
        owner: token_program_id,
        is_signer: false,
        is_writable: true,
    },
    Account {
        address: authority_address,
        lamports: 100_000_000,
        data: vec![],
        owner: system_program_id,
        is_signer: true,
        is_writable: false,
    },
];

// Encode instruction data
let instruction_data = borsh::to_vec(&TransferInstruction {
    amount: 1_000_000_000, // 1 token (9 decimals)
})?;

// Execute program
let result = executor.execute_program(
    &program_address,
    &accounts,
    &instruction_data,
    1_400_000, // Compute budget
).await?;

println!("Compute units used: {}", result.compute_units_used);
println!("Logs: {:?}", result.logs);
println!("Modified accounts: {}", result.modified_accounts.len());

Account Serialization

Solana programs receive account data as a serialized byte array with a specific format. The executor serializes accounts according to Solana's specification: duplicate account count, account metadata, and account data.

use tenzro_vm::svm::account_serialization::serialize_accounts;

// Solana account serialization format:
// - 8 bytes: number of accounts (u64 LE)
// - For each account:
//   - 1 byte: duplicate flag
//   - 1 byte: is_signer flag
//   - 1 byte: is_writable flag
//   - 32 bytes: account public key
//   - 8 bytes: lamports (u64 LE)
//   - 8 bytes: data length (u64 LE)
//   - N bytes: account data
//   - 32 bytes: owner public key
//   - 1 byte: executable flag
//   - 8 bytes: rent_epoch (u64 LE)

fn serialize_accounts(accounts: &[Account]) -> Vec<u8> {
    let mut serialized = Vec::new();

    // Write account count
    serialized.extend_from_slice(&(accounts.len() as u64).to_le_bytes());

    for account in accounts {
        // Duplicate flag (0 = not duplicate)
        serialized.push(0);

        // Flags
        serialized.push(if account.is_signer { 1 } else { 0 });
        serialized.push(if account.is_writable { 1 } else { 0 });

        // Account address (32 bytes)
        serialized.extend_from_slice(account.address.as_bytes());

        // Lamports
        serialized.extend_from_slice(&account.lamports.to_le_bytes());

        // Data length and data
        serialized.extend_from_slice(&(account.data.len() as u64).to_le_bytes());
        serialized.extend_from_slice(&account.data);

        // Owner
        serialized.extend_from_slice(account.owner.as_bytes());

        // Executable flag
        serialized.push(if account.executable { 1 } else { 0 });

        // Rent epoch
        serialized.extend_from_slice(&account.rent_epoch.to_le_bytes());
    }

    serialized
}

// Deserialize modified accounts after execution
fn deserialize_accounts(serialized: &[u8]) -> Result<Vec<Account>, Error> {
    // Parse modified account data from VM output
}

Syscall Interface

SVM programs interact with the runtime through syscalls. The executor implements stubs for common Solana syscalls including logging, hashing, memory operations, and account validation.

Logging Syscalls

// sol_log - Log a message
// Signature: fn sol_log(message: *const u8, len: u64)
fn syscall_sol_log(
    message_ptr: u64,
    message_len: u64,
    memory: &Memory,
    logger: &mut Logger,
) -> u64 {
    let message_bytes = memory.read(message_ptr, message_len as usize);
    let message = String::from_utf8_lossy(&message_bytes);
    logger.log(&format!("Program log: {}", message));
    0 // Success
}

// sol_log_64 - Log 5 64-bit values
// Signature: fn sol_log_64(arg1: u64, arg2: u64, arg3: u64, arg4: u64, arg5: u64)
fn syscall_sol_log_64(
    arg1: u64, arg2: u64, arg3: u64, arg4: u64, arg5: u64,
    logger: &mut Logger,
) -> u64 {
    logger.log(&format!(
        "Program log: {:#x}, {:#x}, {:#x}, {:#x}, {:#x}",
        arg1, arg2, arg3, arg4, arg5
    ));
    0
}

// sol_log_pubkey - Log a public key
// Signature: fn sol_log_pubkey(pubkey: *const u8)
fn syscall_sol_log_pubkey(
    pubkey_ptr: u64,
    memory: &Memory,
    logger: &mut Logger,
) -> u64 {
    let pubkey_bytes = memory.read(pubkey_ptr, 32);
    let pubkey = base58::encode(&pubkey_bytes);
    logger.log(&format!("Program log: {}", pubkey));
    0
}

Cryptographic Syscalls

use sha2::{Sha256, Digest};
use sha3::Keccak256;

// sol_sha256 - Compute SHA-256 hash
// Signature: fn sol_sha256(input: *const u8, len: u64, output: *mut u8) -> u64
fn syscall_sol_sha256(
    input_ptr: u64,
    input_len: u64,
    output_ptr: u64,
    memory: &mut Memory,
) -> u64 {
    let input = memory.read(input_ptr, input_len as usize);
    let hash = Sha256::digest(&input);
    memory.write(output_ptr, &hash);
    0 // Success
}

// sol_keccak256 - Compute Keccak-256 hash
fn syscall_sol_keccak256(
    input_ptr: u64,
    input_len: u64,
    output_ptr: u64,
    memory: &mut Memory,
) -> u64 {
    let input = memory.read(input_ptr, input_len as usize);
    let hash = Keccak256::digest(&input);
    memory.write(output_ptr, &hash);
    0
}

// sol_blake3 - Compute BLAKE3 hash
fn syscall_sol_blake3(
    input_ptr: u64,
    input_len: u64,
    output_ptr: u64,
    memory: &mut Memory,
) -> u64 {
    let input = memory.read(input_ptr, input_len as usize);
    let hash = blake3::hash(&input);
    memory.write(output_ptr, hash.as_bytes());
    0
}

Memory Syscalls

// sol_memcpy - Copy memory
// Signature: fn sol_memcpy(dst: *mut u8, src: *const u8, len: u64)
fn syscall_sol_memcpy(
    dst_ptr: u64,
    src_ptr: u64,
    len: u64,
    memory: &mut Memory,
) -> u64 {
    let data = memory.read(src_ptr, len as usize);
    memory.write(dst_ptr, &data);
    0
}

// sol_memset - Set memory to value
// Signature: fn sol_memset(dst: *mut u8, value: u8, len: u64)
fn syscall_sol_memset(
    dst_ptr: u64,
    value: u8,
    len: u64,
    memory: &mut Memory,
) -> u64 {
    let data = vec![value; len as usize];
    memory.write(dst_ptr, &data);
    0
}

// sol_memmove - Move memory (overlapping regions allowed)
fn syscall_sol_memmove(
    dst_ptr: u64,
    src_ptr: u64,
    len: u64,
    memory: &mut Memory,
) -> u64 {
    // Handle overlapping regions correctly
    let data = memory.read(src_ptr, len as usize);
    memory.write(dst_ptr, &data);
    0
}

// sol_memcmp - Compare memory
fn syscall_sol_memcmp(
    s1_ptr: u64,
    s2_ptr: u64,
    len: u64,
    result_ptr: u64,
    memory: &mut Memory,
) -> u64 {
    let s1 = memory.read(s1_ptr, len as usize);
    let s2 = memory.read(s2_ptr, len as usize);
    let result = s1.cmp(&s2) as i32;
    memory.write(result_ptr, &result.to_le_bytes());
    0
}

Account Validation Syscalls

// sol_create_program_address - Derive program address (PDA)
// Signature: fn sol_create_program_address(
//     seeds: *const u8,
//     seeds_len: u64,
//     program_id: *const u8,
//     address_out: *mut u8,
// ) -> u64
fn syscall_sol_create_program_address(
    seeds_ptr: u64,
    seeds_len: u64,
    program_id_ptr: u64,
    address_out_ptr: u64,
    memory: &mut Memory,
) -> u64 {
    let seeds = memory.read(seeds_ptr, seeds_len as usize);
    let program_id = memory.read(program_id_ptr, 32);

    // Derive PDA using SHA-256
    let mut hasher = Sha256::new();
    hasher.update(&seeds);
    hasher.update(&program_id);
    hasher.update(b"ProgramDerivedAddress");
    let hash = hasher.finalize();

    memory.write(address_out_ptr, &hash[..32]);
    0 // Success
}

// sol_try_find_program_address - Find valid PDA with bump seed
fn syscall_sol_try_find_program_address(
    seeds_ptr: u64,
    seeds_len: u64,
    program_id_ptr: u64,
    address_out_ptr: u64,
    bump_out_ptr: u64,
    memory: &mut Memory,
) -> u64 {
    let base_seeds = memory.read(seeds_ptr, seeds_len as usize);
    let program_id = memory.read(program_id_ptr, 32);

    // Try bump seeds from 255 down to 0
    for bump in (0..=255).rev() {
        let mut seeds = base_seeds.clone();
        seeds.push(bump);

        let mut hasher = Sha256::new();
        hasher.update(&seeds);
        hasher.update(&program_id);
        hasher.update(b"ProgramDerivedAddress");
        let hash = hasher.finalize();

        // Check if address is valid (off the Ed25519 curve)
        if is_valid_pda(&hash) {
            memory.write(address_out_ptr, &hash[..32]);
            memory.write(bump_out_ptr, &[bump]);
            return 0; // Success
        }
    }

    1 // Error: no valid PDA found
}

Compute Unit Metering

SVM uses compute units (CU) instead of gas for resource metering. Each BPF instruction consumes a specific number of compute units, with syscalls consuming additional units based on complexity. The default compute budget is 1,400,000 CU per transaction.

use tenzro_vm::svm::compute_budget::{ComputeBudget, ComputeMeter};

// Default compute budget
const DEFAULT_COMPUTE_UNITS: u64 = 1_400_000;
const MAX_COMPUTE_UNITS: u64 = 1_400_000;

// Instruction costs (in compute units)
const CU_PER_INSTRUCTION: u64 = 1;
const CU_SYSCALL_BASE: u64 = 100;
const CU_SHA256_BASE: u64 = 85;
const CU_SHA256_PER_BYTE: u64 = 1;
const CU_KECCAK256_BASE: u64 = 85;
const CU_KECCAK256_PER_BYTE: u64 = 1;
const CU_MEMCPY_PER_BYTE: u64 = 1;
const CU_LOG: u64 = 100;

let mut meter = ComputeMeter::new(DEFAULT_COMPUTE_UNITS);

// Consume CUs during execution
meter.consume(CU_PER_INSTRUCTION)?; // Each instruction

// Syscall costs
meter.consume(CU_SYSCALL_BASE + CU_SHA256_BASE + data_len * CU_SHA256_PER_BYTE)?;

// Check remaining budget
if meter.remaining() < required_cu {
    return Err(VmError::ComputeBudgetExceeded);
}

println!("Compute units used: {}", meter.used());
println!("Compute units remaining: {}", meter.remaining());

Program Deployment

SVM programs are deployed by writing eBPF bytecode to a program account and marking it as executable. Programs are loaded into the VM on first invocation and cached for subsequent executions.

use tenzro_vm::svm::program_loader::deploy_program;

// Compile Rust program to eBPF
// cargo build-bpf --manifest-path=./my_program/Cargo.toml

let program_bytecode = std::fs::read("my_program.so")?;
let program_address = Address::new_unique();

// Create program account
let program_account = Account {
    address: program_address,
    lamports: 1_000_000, // Rent-exempt minimum
    data: program_bytecode,
    owner: bpf_loader_upgradeable_id,
    executable: true,
    rent_epoch: 0,
};

// Store program in state
state_adapter.create_account(&program_account)?;

// Invoke program
let result = executor.execute_program(
    &program_address,
    &accounts,
    &instruction_data,
    DEFAULT_COMPUTE_UNITS,
).await?;

println!("Program deployed at: {}", program_address);

Cross-Program Invocation

SVM supports cross-program invocation (CPI) where one program calls another. The executor maintains a call stack and enforces privilege escalation rules for signer and writable account access.

// sol_invoke_signed - Invoke another program with signer seeds
// Signature: fn sol_invoke_signed(
//     instruction: *const Instruction,
//     accounts: *const AccountMeta,
//     signers_seeds: *const &[&[u8]],
// ) -> u64

struct CrossProgramInvocation {
    program_id: Address,
    accounts: Vec<AccountMeta>,
    data: Vec<u8>,
    signer_seeds: Vec<Vec<Vec<u8>>>,
}

fn syscall_sol_invoke_signed(
    instruction_ptr: u64,
    accounts_ptr: u64,
    signers_seeds_ptr: u64,
    memory: &Memory,
    executor: &SvmExecutor,
    call_depth: u32,
) -> u64 {
    const MAX_CALL_DEPTH: u32 = 4;

    if call_depth >= MAX_CALL_DEPTH {
        return 1; // Error: call depth exceeded
    }

    // Deserialize CPI instruction
    let instruction = deserialize_instruction(instruction_ptr, memory)?;
    let accounts = deserialize_account_metas(accounts_ptr, memory)?;
    let signer_seeds = deserialize_signer_seeds(signers_seeds_ptr, memory)?;

    // Verify PDAs for signer seeds
    for seeds in signer_seeds {
        let pda = derive_pda(&seeds, &instruction.program_id);
        // Verify PDA is in accounts and marked as signer
    }

    // Execute called program
    let result = executor.execute_program(
        &instruction.program_id,
        &accounts,
        &instruction.data,
        remaining_compute_units,
    ).await?;

    0 // Success
}

Syscall Registry

SyscallDescriptionStatus
sol_logLog message stringImplemented
sol_log_64Log 5 u64 valuesImplemented
sol_log_pubkeyLog public keyImplemented
sol_sha256Compute SHA-256 hashImplemented
sol_keccak256Compute Keccak-256 hashImplemented
sol_memcpyCopy memory regionImplemented
sol_memsetSet memory to valueImplemented
sol_memmoveMove memory (overlap-safe)Implemented
sol_memcmpCompare memory regionsImplemented
sol_create_program_addressDerive program addressStub (SHA-256)
sol_try_find_program_addressFind valid PDA with bumpStub (SHA-256)
sol_invoke_signedCross-program invocationNot implemented

SPL Token Adapter

SVM programs interact with native TNZO through the SPL Token Adapter, which maps standard SPL Token Program instructions to the underlying TnzoToken layer. This allows existing Solana programs to work with TNZO without modification using the familiar SPL token interface.

Decimal Conversion

Native TNZO uses 18 decimals (like Ethereum), while SPL tokens on Solana use 9 decimals. The adapter automatically handles this conversion by truncating the lowest 9 decimal places when presenting balances to SVM programs, and scaling up by 10^9 when writing amounts back to the native layer.

// Decimal conversion between TNZO (18 decimals) and SPL (9 decimals)
//
// Native TNZO:  1_000_000_000_000_000_000  (1.0 TNZO, 18 decimals)
// SPL wTNZO:    1_000_000_000              (1.0 TNZO,  9 decimals)
//
// Conversion: spl_amount = native_amount / 10^9
//             native_amount = spl_amount * 10^9

const DECIMAL_ADJUSTMENT: u64 = 1_000_000_000; // 10^9

fn native_to_spl(native_amount: u128) -> u64 {
    (native_amount / DECIMAL_ADJUSTMENT as u128) as u64
}

fn spl_to_native(spl_amount: u64) -> u128 {
    spl_amount as u128 * DECIMAL_ADJUSTMENT as u128
}

Associated Token Accounts

The adapter derives Associated Token Account (ATA) addresses deterministically from the owner wallet address and the wTNZO mint address. This follows the standard Solana ATA derivation so existing tooling and wallets work without changes.

use tenzro_vm::svm::spl_adapter::{SplTokenAdapter, derive_ata};

// Derive the Associated Token Account for wTNZO
let wtnzo_mint = Address::from_base58("TnzoTkn...")?;
let owner = Address::from_base58("UserWa1...")?;

// ATA = PDA(owner, TOKEN_PROGRAM_ID, mint)
let ata_address = derive_ata(&owner, &wtnzo_mint)?;

// The adapter maps SPL Token instructions to native TNZO operations:
//
//   SPL Transfer         -> TnzoToken::transfer() with decimal scaling
//   SPL GetAccountInfo   -> native balance query with 9-decimal truncation
//   SPL InitializeAccount -> no-op (accounts are auto-initialized)
//   SPL CloseAccount     -> zeroes balance, reclaims rent

let adapter = SplTokenAdapter::new(state_adapter.clone());

// Transfer 5.0 TNZO via SPL interface (amount in 9-decimal SPL units)
let spl_amount = 5_000_000_000; // 5.0 TNZO in SPL decimals
adapter.transfer(&from_ata, &to_ata, spl_amount)?;
// Internally calls TnzoToken::transfer() with 5_000_000_000_000_000_000 (18 decimals)

Pointer model: Like the EVM wTNZO pointer contract, the SVM SPL adapter shares the same underlying native TNZO balance. There is no separate token supply or bridge. A transfer on SVM is immediately reflected in EVM and Canton balances. See the Cross-VM Tokens documentation for the full cross-VM architecture.

Configuration

use tenzro_vm::svm::{SvmExecutor, SvmConfig};

let config = SvmConfig {
    compute_budget: 1_400_000,
    enable_syscalls: true,
    max_account_data_size: 10_485_760, // 10 MB
    max_instruction_data_size: 1_280, // 1.25 KB
    max_call_depth: 4,
    enable_cpi: true,
    log_compute_units: true,
};

let executor = SvmExecutor::with_config(config, state_adapter);

// Override compute budget per transaction
executor.set_compute_budget(200_000); // Lower budget for simple operations

Compute Budget Constants

ConstantValueDescription
DEFAULT_COMPUTE_UNITS1,400,000Default budget per transaction
MAX_COMPUTE_UNITS1,400,000Maximum budget per transaction
CU_PER_INSTRUCTION1Cost per BPF instruction
CU_SYSCALL_BASE100Base cost for any syscall
CU_SHA256_BASE85SHA-256 base cost
CU_SHA256_PER_BYTE1SHA-256 per byte
CU_LOG100Cost per log operation
MAX_CALL_DEPTH4Maximum CPI call depth
MAX_ACCOUNT_DATA_SIZE10 MBMaximum account data size

Testing

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_simple_program_execution() {
        let state = InMemoryStateAdapter::new();
        let executor = SvmExecutor::new(state.clone());

        // Deploy simple program
        let program_bytecode = compile_test_program()?;
        let program_id = deploy_program(&state, program_bytecode)?;

        // Execute program
        let result = executor.execute_program(
            &program_id,
            &accounts,
            &instruction_data,
            DEFAULT_COMPUTE_UNITS,
        ).await?;

        assert!(result.success);
        assert!(result.compute_units_used < DEFAULT_COMPUTE_UNITS);
    }

    #[tokio::test]
    async fn test_syscall_logging() {
        // Test sol_log syscall captures output
    }

    #[tokio::test]
    async fn test_compute_budget_exceeded() {
        // Test execution fails when budget exhausted
    }
}

Production Readiness

Production-Ready:

  • Real BPF program execution via solana_rbpf
  • Solana-format account serialization
  • Compute unit metering and budget enforcement
  • Syscall stubs for logging, hashing, and memory operations
  • Program deployment and caching

Outstanding Issues:

  • PDA derivation uses SHA-256 instead of Ed25519 curve-compliant derivation (issue #85)
  • Cross-program invocation not implemented
  • State adapter does not persist to storage (issue #25)
  • No transaction signature verification (issue #26)