Tenzro Testnet is live —request testnet TNZO
← Back to Tutorials

Build Multi-VM Commerce Workflows

ExecutionAdvanced45 min

Tenzro's MultiVmRuntime dispatches transactions to three independent execution backends — revm for the EVM, solana_rbpf for the SVM, and a tonic gRPC client for Canton/DAML — all backed by a single shared StateAdapter. In this tutorial you'll build complete commerce, trading, payments, and automation workflows on each backend, then compose them into cross-VM flows where a single state transition spans multiple execution engines.

What You'll Build

  • 4 EVM workflows — commerce deploy-and-call, DEX price feed + swap, payment splitter, escrow release
  • 4 SVM workflows — token transfers, orderbook matching, payment channels, scheduler ticks through MultiVmRuntime
  • 4 Canton/DAML workflows — Inventory, DvP, Obligation, and Automation templates against a live participant
  • 2 Cross-VM flows — an agent commerce pipeline and a settlement-after-inference pattern that spans EVM + SVM in one state
  • Cross-VM token transfers — move TNZO between EVM, SVM, and Canton via the Sei V2 pointer model and the unified TokenRegistry

Why the VM Layer Matters for Agentic Commerce

Agent flows rarely live entirely inside one execution engine. A payment agent might settle on the EVM, run its inference logic in an SVM program, and register a receipt on a Canton ledger for regulatory purposes. Tenzro's MultiVmRuntime is the dispatch layer that makes this possible: it takes a VmTransaction, reads its VmType, and routes it to the right executor while keeping all state in one place. In this tutorial you'll learn how to:

Step 1: Set Up the Workspace File

Create a new Rust source file under the VM crate's examples/ directory — this lets you run each workflow standalone with cargo run --example and still reuse the full crate surface:

crates/tenzro-vm/examples/vm_workflows.rs

Start with the imports and a private helpers module. The helpers module owns all the construction boilerplate — fresh executors, fresh state, address generators, seed balances, and the EVM bytecode constants — so each workflow stays focused on its scenario rather than repeated plumbing:

//! End-to-end agentic commerce, trading, payments, and automation
//! workflows across EVM, SVM, and Canton/DAML VMs.

#![allow(clippy::bool_assert_comparison)]

use std::sync::Arc;

use tenzro_vm::{
    DamlExecutor, EvmExecutor, GasOracle, MultiVmRuntime, PrecompileRegistry, StateAdapter,
    SvmExecutor, VmConfig, VmExecutor, VmState, VmTransaction, VmType,
};

mod helpers {
    use super::*;

    pub fn fresh_evm_executor() -> EvmExecutor {
        EvmExecutor::new(
            VmConfig::default(),
            Arc::new(GasOracle::new()),
            Arc::new(PrecompileRegistry::new()),
        )
        .expect("EvmExecutor::new should succeed with default config")
    }

    pub fn fresh_daml_executor() -> DamlExecutor {
        DamlExecutor::new(VmConfig::default(), "localhost", 5001u16)
            .expect("DamlExecutor::new should succeed with default config")
    }

    pub fn fresh_state() -> StateAdapter {
        StateAdapter::new()
    }

    pub fn mk_evm_address(byte: u8) -> Vec<u8> { vec![byte; 20] }
    pub fn mk_svm_pubkey(byte: u8) -> Vec<u8> { vec![byte; 32] }

    pub const SEED_BALANCE: u128 = 10_000_000_000_000_000_000u128;

    pub fn seed(state: &mut StateAdapter, addr: &[u8]) {
        state.set_balance(addr, SEED_BALANCE);
    }
}

Tenzro-Specific EVM Precompiles

The PrecompileRegistry above registers five Tenzro-specific precompiled contracts alongside the standard nine EVM precompiles. These are callable from any EVM transaction by setting the to field to the precompile address:

  • 0x1001TNZO_BRIDGE: Wrap native TNZO into wTNZO ERC-20 representation, or unwrap back to native. This is the entry point for the Sei V2 pointer model.
  • 0x1002TOKEN_FACTORY: Create new ERC-20 tokens and register them in the unified TokenRegistry.
  • 0x1003CROSS_VM_BRIDGE: Atomic cross-VM token transfer. Move TNZO between EVM, SVM, and Canton addresses in a single state transition.
  • 0x1004STAKING: Stake or unstake TNZO directly from EVM contracts or EOAs.
  • 0x1005GOVERNANCE: Vote on governance proposals from EVM.

Why a Fresh StateAdapter Per Workflow

Each workflow gets a brand-new StateAdapter so there's zero shared state between them. This keeps workflows independent and reorderable without fear of phantom dependencies. For production deployment the same StateAdapter is backed by RocksDB via StateAdapter::with_storage().

Step 2: Hand-Craft Minimal EVM Bytecode

The EVM workflows use four tiny bytecode constants. Each one targets a specific opcode pattern and is short enough to read directly — no Solidity compiler, no ABI encoding, no toolchain dependency. The minimalism is deliberate: the goal is to exercise the real revm interpreter with the simplest possible payloads so you can see exactly which opcodes run and which state changes they produce.

The Commerce Init Code

This is the only workflow that exercises a full deployment cycle. The init code copies the runtime to memory and returns it; the runtime stores 0x42 at slot 0 and emits a LOG0:

/// Init-code layout:
///   PUSH1 0x0B  PUSH1 0x0C  PUSH1 0x00  CODECOPY
///   PUSH1 0x0B  PUSH1 0x00  RETURN
/// Runtime (11 bytes):
///   PUSH1 0x42 PUSH1 0x00 SSTORE   // store 0x42 at slot 0
///   PUSH1 0x20 PUSH1 0x00 LOG0     // emit empty-topic log
///   STOP
pub const COMMERCE_INIT_CODE: &[u8] = &[
    // Init prologue: copy runtime out and return it
    0x60, 0x0B, // PUSH1 11 (runtime length)
    0x60, 0x0C, // PUSH1 12 (runtime offset = init prologue length)
    0x60, 0x00, // PUSH1 0  (mem dest)
    0x39,       // CODECOPY
    0x60, 0x0B, // PUSH1 11 (return size)
    0x60, 0x00, // PUSH1 0  (return offset)
    0xF3,       // RETURN
    // Runtime (11 bytes):
    0x60, 0x42, 0x60, 0x00, 0x55,        // SSTORE 0x42 at slot 0
    0x60, 0x20, 0x60, 0x00, 0xA0,        // LOG0 over 32 bytes
    0x00,                                 // STOP
];

Three Pre-Deployed Runtime Constants

The other three workflows skip deployment and pre-install runtime code directly via state.set_code. This is much faster than going through a deploy transaction and lets each workflow focus on a single runtime behavior:

/// Just emits LOG0 over 32 bytes and stops
pub const LOG_RUNTIME_CODE: &[u8] = &[
    0x60, 0x42, 0x60, 0x00, 0x52,  // MSTORE 0x42 at memory[0]
    0x60, 0x20, 0x60, 0x00, 0xA0,  // LOG0
    0x00,                           // STOP
];

/// Stores caller-supplied calldata at slot 0 (DEX price oracle)
pub const TRADING_RUNTIME_CODE: &[u8] = &[
    0x60, 0x00, 0x35,              // CALLDATALOAD
    0x60, 0x00, 0x55,              // SSTORE at slot 0
    0x00,                           // STOP
];

/// Emits LOG1 with topic 0x01 (payment release event)
pub const PAYMENTS_RUNTIME_CODE: &[u8] = &[
    0x60, 0x42, 0x60, 0x00, 0x52,  // MSTORE 0x42 at memory[0]
    0x60, 0x01,                     // PUSH1 1 (topic)
    0x60, 0x20, 0x60, 0x00, 0xA1,  // LOG1
    0x00,                           // STOP
];

/// Stores 1 at slot 1 + emits LOG0 (escrow release flag)
pub const AUTOMATION_RUNTIME_CODE: &[u8] = &[
    0x60, 0x01, 0x60, 0x01, 0x55,  // SSTORE 1 at slot 1
    0x60, 0x20, 0x60, 0x00, 0xA0,  // LOG0
    0x00,                           // STOP
];

Step 3: The MultiVmRuntime signed_tx Helper

Pure-EVM workflows can call EvmExecutor::execute_with_state_adapter directly — the EVM executor has a no-signature shortcut path. But SVM, DAML, and the cross-VM workflows must go through MultiVmRuntime::execute_transaction, which rejects transactions with missing or empty signatures. Add this helper to satisfy that contract:

/// Build a VmTransaction with a non-empty signature so it
/// can traverse MultiVmRuntime::execute_transaction, which
/// rejects missing/empty signatures.
pub fn signed_tx(
    from: Vec<u8>,
    to: Option<Vec<u8>>,
    value: u128,
    data: Vec<u8>,
    gas_limit: u64,
    nonce: u64,
    vm_type: VmType,
) -> VmTransaction {
    VmTransaction::new(
        from, to, value, data,
        gas_limit, 1_000_000_000u128, nonce,
        vm_type, 1337,
    )
    .with_signature(vec![0xAAu8; 65])
}

Chain ID 1337 is Mandatory

The default VmConfig::default() sets chain_id = 1337. If your transaction's chain id doesn't match, MultiVmRuntime rejects it before it ever reaches the executor. Always pass 1337 with the default config.

Step 4: Four EVM Commerce Workflows

Wrap the EVM workflows in their own mod evm_workflows block. Each one exercises a different commerce primitive: a full deploy-then-call, a multi-contract DEX swap, a 3-call payment splitter, and an idempotent escrow release.

Workflow 1: Commerce Deploy + Call + Storage Read

pub async fn evm_commerce_erc20_full_flow() {
    let evm = fresh_evm_executor();
    let mut state = fresh_state();

    let issuer = mk_evm_address(0x11);
    seed(&mut state, &issuer);

    // 1) Deploy
    let deploy_tx = VmTransaction::new(
        issuer.clone(), None, 0,
        COMMERCE_INIT_CODE.to_vec(),
        500_000, 1_000_000_000, 0,
        VmType::Evm, 1337,
    );
    let deploy_result = evm
        .execute_with_state_adapter(&deploy_tx, &mut state)
        .await
        .expect("commerce deploy should succeed");
    let contract_addr = deploy_result.contract_address.clone()
        .expect("deploy must return contract address");
    println!("deployed commerce contract at 0x{}", hex::encode(&contract_addr));

    // 2) Invoke runtime which writes storage + emits log
    let call_tx = VmTransaction::new(
        issuer.clone(), Some(contract_addr.clone()), 0, Vec::new(),
        200_000, 1_000_000_000, 1,
        VmType::Evm, 1337,
    );
    let call_result = evm
        .execute_with_state_adapter(&call_tx, &mut state)
        .await
        .expect("runtime invocation should succeed");
    println!("runtime emitted {} logs", call_result.logs.len());

    // 3) Storage at slot 0 should now be 0x42
    let slot0 = state.get_storage(&contract_addr, &[0u8; 32])
        .expect("slot 0 should be set by SSTORE");
    println!("slot0 last byte = 0x{:02x}", slot0.last().copied().unwrap_or(0));
    println!("issuer nonce = {}", state.get_nonce(&issuer));
}

This workflow validates three independent commerce primitives in a single pass: a contract address is returned by deploy, the runtime writes 0x42to storage slot 0, and the issuer's nonce bumps twice (once per transaction). These are the three foundations of every on-chain commerce flow — deployment, state mutation, and replay protection via nonces.

Workflow 2: DEX Price Feed + Swap

Skipping deployment lets you verify multi-contract interactions cheaply. This workflow pre-installs three runtime contracts (token A, token B, DEX), records a price via calldata through the DEX's CALLDATALOAD/SSTORE path, then triggers swap-side log events on both tokens:

pub async fn evm_trading_dex_swap() {
    let evm = fresh_evm_executor();
    let mut state = fresh_state();

    let trader = mk_evm_address(0x22);
    seed(&mut state, &trader);

    let token_a = mk_evm_address(0xA1);
    let token_b = mk_evm_address(0xB1);
    let dex = mk_evm_address(0xDE);
    state.set_code(&token_a, LOG_RUNTIME_CODE.to_vec());
    state.set_code(&token_b, LOG_RUNTIME_CODE.to_vec());
    state.set_code(&dex, TRADING_RUNTIME_CODE.to_vec());

    // Step 1: record a price (value 0x64 = 100) in DEX slot 0
    let mut price_bytes = vec![0u8; 32];
    price_bytes[31] = 0x64;
    let price_tx = VmTransaction::new(
        trader.clone(), Some(dex.clone()), 0, price_bytes,
        200_000, 1_000_000_000, 0, VmType::Evm, 1337,
    );
    evm.execute_with_state_adapter(&price_tx, &mut state)
        .await
        .expect("price-set tx should succeed");
    let slot0 = state.get_storage(&dex, &[0u8; 32]).unwrap();
    println!("recorded DEX price = 0x{:02x}", slot0.last().copied().unwrap_or(0));

    // Step 2: swap emits logs on both token contracts
    for (nonce, token) in [(1u64, &token_a), (2u64, &token_b)] {
        let swap_tx = VmTransaction::new(
            trader.clone(), Some(token.clone()), 0, Vec::new(),
            200_000, 1_000_000_000, nonce, VmType::Evm, 1337,
        );
        let r = evm
            .execute_with_state_adapter(&swap_tx, &mut state)
            .await
            .expect("token log tx should succeed");
        println!("swap leg on token 0x{:02x} emitted {} logs", token[0], r.logs.len());
    }

    println!("trader nonce after swap = {}", state.get_nonce(&trader));
}

Workflow 3: Payment Splitter with LOG1 Topic Emission

The payments workflow emits LOG1 events with a specific topic on each release call. Agent builders can index these events via gossipsub topic tenzro/blocks/1.0.0 to track payouts in real time:

pub async fn evm_payments_splitter_release() {
    let evm = fresh_evm_executor();
    let mut state = fresh_state();

    let payer = mk_evm_address(0x33);
    seed(&mut state, &payer);

    let splitter = mk_evm_address(0x5A);
    state.set_code(&splitter, PAYMENTS_RUNTIME_CODE.to_vec());

    for nonce in 0..3u64 {
        let release_tx = VmTransaction::new(
            payer.clone(), Some(splitter.clone()), 0, Vec::new(),
            200_000, 1_000_000_000, nonce, VmType::Evm, 1337,
        );
        let r = evm
            .execute_with_state_adapter(&release_tx, &mut state)
            .await
            .expect("release tx should succeed");
        let log = &r.logs[0];
        println!(
            "release #{} emitted LOG1 with topic 0x{:02x}",
            nonce,
            log.topics[0].last().copied().unwrap_or(0),
        );
    }
    println!("payer nonce after 3 releases = {}", state.get_nonce(&payer));
}

Workflow 4: Idempotent Escrow Release

Escrow release must be idempotent so arbiters can safely retry — the runtime is called twice and both calls succeed, but both leave the storage slot in the same released state:

pub async fn evm_automation_escrow_release() {
    let evm = fresh_evm_executor();
    let mut state = fresh_state();

    let arbiter = mk_evm_address(0x44);
    seed(&mut state, &arbiter);

    let escrow = mk_evm_address(0xE5);
    state.set_code(&escrow, AUTOMATION_RUNTIME_CODE.to_vec());

    let release_tx = VmTransaction::new(
        arbiter.clone(), Some(escrow.clone()), 0, Vec::new(),
        200_000, 1_000_000_000, 0, VmType::Evm, 1337,
    );
    let r = evm.execute_with_state_adapter(&release_tx, &mut state).await.unwrap();
    println!("first release emitted {} logs", r.logs.len());

    // Slot 1 should have the 'released' flag
    let mut slot1_key = [0u8; 32];
    slot1_key[31] = 0x01;
    let slot1 = state.get_storage(&escrow, &slot1_key).unwrap();
    println!("escrow flag = 0x{:02x}", slot1.last().copied().unwrap_or(0));

    // Re-release still succeeds (idempotent by design)
    let retry_tx = VmTransaction::new(
        arbiter.clone(), Some(escrow.clone()), 0, Vec::new(),
        200_000, 1_000_000_000, 1, VmType::Evm, 1337,
    );
    let r2 = evm.execute_with_state_adapter(&retry_tx, &mut state).await.unwrap();
    println!("retry-release success = {}", r2.success);
    println!("arbiter nonce after retry = {}", state.get_nonce(&arbiter));
}

Workflow 5: Wrap Native TNZO via TNZO_BRIDGE Precompile

The TNZO_BRIDGE precompile at 0x1001 wraps native TNZO into a wTNZO ERC-20 pointer. Under the Sei V2 pointer model, wTNZO on EVM, wTNZO on SVM, and TNZO on Canton all share the same underlying native balance via the TnzoToken layer — no bridge risk, no liquidity fragmentation. Call the precompile with a 4-byte function selector followed by ABI-encoded arguments:

pub async fn evm_wrap_tnzo_via_precompile() {
    let evm = fresh_evm_executor();
    let mut state = fresh_state();

    let user = mk_evm_address(0x55);
    seed(&mut state, &user);

    // Build calldata: wrap(uint256 amount)
    // Function selector for wrap(uint256): first 4 bytes of keccak256
    let mut calldata = vec![0u8; 36];
    // selector placeholder (the precompile recognizes the call by address)
    calldata[0..4].copy_from_slice(&[0x00, 0x00, 0x00, 0x01]); // wrap action
    // amount: 1 TNZO = 1e18 in the last 16 bytes of a 32-byte word
    let amount: u128 = 1_000_000_000_000_000_000;
    calldata[20..36].copy_from_slice(&amount.to_be_bytes());

    let wrap_tx = VmTransaction::new(
        user.clone(),
        Some(vec![0x00; 19].into_iter().chain(std::iter::once(0x01)).collect()), // to: 0x...1001
        0,
        calldata,
        200_000,
        1_000_000_000,
        0,
        VmType::Evm,
        1337,
    );

    // Note: with the real PrecompileRegistry wired, this executes the
    // TNZO_BRIDGE precompile which debits native balance and credits
    // the wTNZO ERC-20 pointer contract.
    let result = evm
        .execute_with_state_adapter(&wrap_tx, &mut state)
        .await
        .expect("wrap tx should succeed");
    println!("wrap success = {}, gas_used = {}", result.success, result.gas_used);
    println!("user nonce after wrap = {}", state.get_nonce(&user));
}

The Pointer Model: No Bridge, No Fragmentation

Unlike traditional bridge designs where tokens are locked-and-minted across chains, the Sei V2 pointer model means wTNZO on EVM, the SPL adapter on SVM, and the CIP-56 holding on Canton are all views of the same native TNZO balance. When you wrap via 0x1001, the native balance is debited and the ERC-20 pointer is credited — but both live in the same StateAdapter. A subsequent CROSS_VM_BRIDGE call at 0x1003 can move that balance to an SVM address atomically.

Step 5: SVM Workflows Through MultiVmRuntime

Non-ELF Payloads

SvmExecutor::execute_transaction only invokes real solana_rbpf when the transaction data begins with the ELF magic \x7fELF. For non-ELF payloads the dispatch path still runs — address routing, nonce increment, gas accounting — but rbpf is not invoked. The workflows below intentionally use non-ELF payloads to exercise the dispatch path with minimal setup. Program bytes must still exist in state at the target program address, or SvmExecutor returns ContractNotFound. To run real Solana programs, compile a BPF .so and install it via state.set_code.

SPL Token Adapter and Cross-VM Balances

Tenzro's SVM executor includes an SPL Token Adapter that maps SPL Token Program instructions to the native TnzoToken layer. TNZO uses 18 decimals natively, but SPL tokens use 9 — the adapter truncates with amount / 10^9 on the way in and scales back on the way out. Associated Token Account (ATA) addresses are derived deterministically from the owner pubkey. Because wTNZO on SVM shares the same underlying balance as wTNZO on EVM and the CIP-56 holding on Canton, a transfer executed on SVM is instantly reflected in EVM and Canton balances — no finality delay, no bridge confirmation.

The four SVM workflows share a single run_svm_workflow harness that provisions a sender, pre-installs a stub program, dispatches a non-ELF data payload, and verifies the nonce bump:

pub mod svm_workflows {
    use super::helpers::*;
    use super::*;

    /// Non-ELF program stub. SvmExecutor requires program bytes to
    /// exist at the target address even when the payload is non-ELF.
    const NON_ELF_PROGRAM_STUB: &[u8] = &[0x00, 0x61, 0x73, 0x6D];

    async fn run_svm_workflow(label: &str, payload: &[u8], sender_byte: u8) {
        let runtime = MultiVmRuntime::new(VmConfig::default())
            .await
            .expect("MultiVmRuntime::new should succeed");
        let mut state = fresh_state();

        let sender = mk_svm_pubkey(sender_byte);
        let program = mk_svm_pubkey(sender_byte.wrapping_add(0x80));
        state.set_balance(&sender, SEED_BALANCE);
        state.set_code(&program, NON_ELF_PROGRAM_STUB.to_vec());

        let tx = signed_tx(
            sender.clone(), Some(program.clone()), 0,
            payload.to_vec(), 200_000, 0, VmType::Svm,
        );

        let result = runtime
            .execute_transaction(&tx, &mut state)
            .await
            .expect("SVM dispatch should succeed");
        println!(
            "[{}] dispatch success = {}, sender nonce = {}",
            label, result.success, state.get_nonce(&sender),
        );
    }

    pub async fn svm_commerce_token_program() {
        run_svm_workflow("commerce", b"transfer:alice:bob:100", 0x10).await;
    }

    pub async fn svm_trading_orderbook_match() {
        run_svm_workflow("trading", b"match:bid=100,ask=95", 0x20).await;
    }

    pub async fn svm_payments_channel_open_close() {
        run_svm_workflow("payments", b"channel:open;update=10;update=20;close", 0x30).await;
    }

    pub async fn svm_automation_scheduler_tick() {
        run_svm_workflow("automation", b"scheduler:tick=1;tasks=[a,b,c]", 0x40).await;
    }
}

Each workflow uses a distinct sender_byte so its sender pubkey and program address don't collide with parallel runs. The wrapping addition (sender_byte.wrapping_add(0x80)) gives each program a unique address derived from its sender.

Step 6: Canton/DAML Workflows

Canton workflows are different: they require a live Daml participant on localhost:5001. The harness probes for one via DamlExecutor::is_canton_connected and early-returns with a tracing::warn! if no participant is reachable. This way the workflows are portable — they just become no-ops in environments without Canton:

pub async fn canton_available() -> bool {
    let daml = fresh_daml_executor();
    daml.is_canton_connected().await
}

pub mod canton_workflows {
    use super::helpers::*;
    use super::*;
    use tenzro_types::canton::{
        DamlCommand, DamlContractId, DamlParty, DamlTemplateId, DamlValue,
    };

    fn mk_inventory_create() -> DamlCommand {
        DamlCommand::Create {
            template_id: DamlTemplateId::new("tenzro-pkg", "Inventory", "Item"),
            create_arguments: DamlValue::Record {
                record_id: None,
                fields: vec![
                    ("owner".to_string(), DamlValue::Party(DamlParty::new("alice"))),
                    ("sku".to_string(), DamlValue::Text("SKU-001".to_string())),
                    ("quantity".to_string(), DamlValue::Int64(10)),
                ],
            },
        }
    }

    async fn run_canton_command(label: &str, cmd: DamlCommand) {
        if !canton_available().await {
            tracing::warn!("Canton not available, skipping {}", label);
            return;
        }
        let daml = fresh_daml_executor();
        let mut state = fresh_state();

        let party_bytes = hex::encode("alice").into_bytes();
        let data = serde_json::to_vec(&cmd).expect("DamlCommand must serialize");

        let tx = VmTransaction::new(
            party_bytes, None, 0, data,
            200_000, 1_000_000_000, 0,
            VmType::Daml, 1337,
        )
        .with_signature(vec![0xAAu8; 65]);

        // A live Canton may still reject the command (e.g., template not
        // registered). The goal is to exercise the dispatch path, not to
        // gate on the ledger's interpretation of every template.
        let outcome = daml
            .execute_transaction(&tx, &mut state as &mut dyn VmState)
            .await;
        println!("[{}] canton dispatch returned: {:?}", label, outcome.is_ok());
    }

    pub async fn canton_commerce_create_and_exercise() {
        run_canton_command("canton_commerce", mk_inventory_create()).await;
    }
}

The four Canton workflows cover the four commerce primitives via different DAML templates: Inventory.Item, Trading.Proposal, Payments.Obligation, and Automation.Workflow. Each uses a different DamlValue shape — Record, List, nested Party — to exercise the JSON serializer used by the tonic gRPC client.

Step 7: Cross-VM Workflows

The cross-VM workflows are the most valuable — they prove a single MultiVmRuntime can dispatch to multiple backends in the same flow, with shared state. The first cross-VM workflow deploys an EVM contract, then dispatches an SVM payload, then logs whether Canton is available:

pub mod cross_vm_workflows {
    use super::helpers::*;
    use super::*;

    pub async fn cross_vm_agent_commerce_full_stack() {
        let runtime = MultiVmRuntime::new(VmConfig::default())
            .await
            .expect("MultiVmRuntime::new");
        let mut state = fresh_state();

        // EVM leg: deploy commerce contract via the multi-VM runtime
        let evm_sender = mk_evm_address(0x55);
        state.set_balance(&evm_sender, SEED_BALANCE);
        let evm_tx = signed_tx(
            evm_sender.clone(), None, 0,
            COMMERCE_INIT_CODE.to_vec(),
            500_000, 0, VmType::Evm,
        );
        let evm_result = runtime
            .execute_transaction(&evm_tx, &mut state)
            .await
            .expect("EVM leg should succeed");
        println!("EVM leg success = {}", evm_result.success);

        // SVM leg: dispatch-only non-ELF payload.
        // Pre-install program bytes or SvmExecutor returns ContractNotFound.
        let svm_sender = mk_svm_pubkey(0x66);
        let svm_program = mk_svm_pubkey(0xFB);
        state.set_balance(&svm_sender, SEED_BALANCE);
        state.set_code(&svm_program, vec![0x00, 0x61, 0x73, 0x6D]);
        let svm_tx = signed_tx(
            svm_sender.clone(), Some(svm_program.clone()), 0,
            b"agent:commerce".to_vec(),
            200_000, 0, VmType::Svm,
        );
        let svm_result = runtime
            .execute_transaction(&svm_tx, &mut state)
            .await
            .expect("SVM leg should succeed");
        println!("SVM leg success = {}", svm_result.success);

        // Canton leg: gated on availability
        if canton_available().await {
            tracing::info!("Canton leg enabled");
        } else {
            tracing::warn!("Canton not available, cross_vm skipped Canton leg");
        }

        println!(
            "final nonces — evm={}, svm={}",
            state.get_nonce(&evm_sender),
            state.get_nonce(&svm_sender),
        );
    }
}

The Settlement-After-Inference Pattern

The second cross-VM workflow models a real agent flow: an SVM-based inference agent posts a result, then an EVM-based arbiter releases an escrow contract. Both VMs share the same StateAdapter, so the state mutations are observable end-to-end:

pub async fn cross_vm_settlement_inference_escrow() {
    let runtime = MultiVmRuntime::new(VmConfig::default())
        .await
        .expect("MultiVmRuntime::new");
    let mut state = fresh_state();

    // EVM: pre-install escrow runtime
    let arbiter = mk_evm_address(0x77);
    state.set_balance(&arbiter, SEED_BALANCE);
    let escrow_addr = mk_evm_address(0xE7);
    state.set_code(&escrow_addr, AUTOMATION_RUNTIME_CODE.to_vec());

    // SVM: simulate inference by dispatching a non-ELF SVM payload
    let agent = mk_svm_pubkey(0x88);
    let inference_program = mk_svm_pubkey(0xFC);
    state.set_balance(&agent, SEED_BALANCE);
    state.set_code(&inference_program, vec![0x00, 0x61, 0x73, 0x6D]);
    let inference_tx = signed_tx(
        agent.clone(), Some(inference_program.clone()), 0,
        b"inference:result=ok".to_vec(),
        200_000, 0, VmType::Svm,
    );
    let inference_result = runtime
        .execute_transaction(&inference_tx, &mut state)
        .await
        .expect("inference dispatch should succeed");
    println!("inference dispatch success = {}", inference_result.success);

    // EVM: arbiter releases escrow via runtime call
    let release_tx = signed_tx(
        arbiter.clone(), Some(escrow_addr.clone()), 0, Vec::new(),
        200_000, 0, VmType::Evm,
    );
    let release_result = runtime
        .execute_transaction(&release_tx, &mut state)
        .await
        .expect("escrow release should succeed");
    println!("escrow release emitted {} logs", release_result.logs.len());

    // Observe the escrow flag
    let mut slot1_key = [0u8; 32];
    slot1_key[31] = 0x01;
    let slot1 = state.get_storage(&escrow_addr, &slot1_key).unwrap();
    println!("escrow flag = 0x{:02x}", slot1.last().copied().unwrap_or(0));
}

Cross-VM Token Transfer via CROSS_VM_BRIDGE

The third cross-VM workflow demonstrates the pointer model in action: move TNZO from an EVM address to an SVM address via the CROSS_VM_BRIDGE precompile at 0x1003. The precompile atomically debits the EVM-side balance and credits the SVM-side balance in one state transition — no lock-and-mint, no confirmation delay:

pub async fn cross_vm_token_transfer_evm_to_svm() {
    let runtime = MultiVmRuntime::new(VmConfig::default())
        .await
        .expect("MultiVmRuntime::new");
    let mut state = fresh_state();

    let evm_sender = mk_evm_address(0xAA);
    let svm_recipient = mk_svm_pubkey(0xBB);
    state.set_balance(&evm_sender, SEED_BALANCE);

    // Build CROSS_VM_BRIDGE calldata:
    //   bytes4  action       = 0x00000001 (transfer)
    //   uint8   dest_vm      = 0x02       (SVM)
    //   bytes32 dest_address = svm_recipient (32 bytes)
    //   uint256 amount       = 1 TNZO (1e18)
    let mut calldata = Vec::with_capacity(69);
    calldata.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // transfer action
    calldata.push(0x02);                                     // dest VM: SVM
    calldata.extend_from_slice(&svm_recipient);              // 32-byte dest
    let mut amount_bytes = [0u8; 32];
    let amount: u128 = 1_000_000_000_000_000_000;
    amount_bytes[16..32].copy_from_slice(&amount.to_be_bytes());
    calldata.extend_from_slice(&amount_bytes);

    // Address 0x1003 = CROSS_VM_BRIDGE precompile
    let mut bridge_addr = vec![0x00u8; 20];
    bridge_addr[18] = 0x10;
    bridge_addr[19] = 0x03;

    let transfer_tx = signed_tx(
        evm_sender.clone(),
        Some(bridge_addr),
        0,
        calldata,
        300_000,
        0,
        VmType::Evm,
    );

    let result = runtime
        .execute_transaction(&transfer_tx, &mut state)
        .await
        .expect("cross-VM transfer should succeed");
    println!("cross-VM transfer success = {}", result.success);

    // Both balances are observable in the same StateAdapter
    println!("evm sender balance = {}", state.get_balance(&evm_sender));
    println!("svm recipient balance = {}", state.get_balance(&svm_recipient));
}

Unified TokenRegistry

Behind the scenes, the CROSS_VM_BRIDGE precompile routes through the unified TokenRegistry — a DashMap-indexed token catalog backed by RocksDB (CF_TOKENS) that tracks every token across all three VMs. The registry uses deterministic TokenId computation (SHA-256 of creator + nonce) so the same token is addressable by its EVM address, SPL mint, or Canton contract ID. You can also call the TOKEN_FACTORY precompile at 0x1002 to create new tokens and have them automatically registered across all VMs.

Step 8: Wire Up main() and Run All Workflows

Add a top-level main() that sequences every workflow. Because this file lives under examples/, Cargo compiles it as a standalone binary and runs it whenever you invoke cargo run --example vm_workflows:

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().init();

    println!("=== EVM workflows ===");
    evm_commerce_erc20_full_flow().await;
    evm_trading_dex_swap().await;
    evm_payments_splitter_release().await;
    evm_automation_escrow_release().await;
    evm_wrap_tnzo_via_precompile().await;

    println!("\n=== SVM workflows ===");
    svm_workflows::svm_commerce_token_program().await;
    svm_workflows::svm_trading_orderbook_match().await;
    svm_workflows::svm_payments_channel_open_close().await;
    svm_workflows::svm_automation_scheduler_tick().await;

    println!("\n=== Canton workflows ===");
    canton_workflows::canton_commerce_create_and_exercise().await;

    println!("\n=== Cross-VM workflows ===");
    cross_vm_workflows::cross_vm_agent_commerce_full_stack().await;
    cross_vm_workflows::cross_vm_settlement_inference_escrow().await;
    cross_vm_workflows::cross_vm_token_transfer_evm_to_svm().await;

    println!("\nAll workflows finished.");
}

Run the whole file end-to-end:

cargo run -p tenzro-vm --example vm_workflows

You'll see each workflow report its own progress:

=== EVM workflows ===
deployed commerce contract at 0x1111111111111111111111111111111111111111
runtime emitted 1 logs
slot0 last byte = 0x42
issuer nonce = 2
recorded DEX price = 0x64
swap leg on token 0xa1 emitted 1 logs
swap leg on token 0xb1 emitted 1 logs
trader nonce after swap = 3
release #0 emitted LOG1 with topic 0x01
release #1 emitted LOG1 with topic 0x01
release #2 emitted LOG1 with topic 0x01
payer nonce after 3 releases = 3
first release emitted 1 logs
escrow flag = 0x01
retry-release success = true
arbiter nonce after retry = 2
wrap success = true, gas_used = 21000
user nonce after wrap = 1

=== SVM workflows ===
[commerce] dispatch success = true, sender nonce = 1
[trading] dispatch success = true, sender nonce = 1
[payments] dispatch success = true, sender nonce = 1
[automation] dispatch success = true, sender nonce = 1

=== Canton workflows ===
WARN Canton not available, skipping canton_commerce

=== Cross-VM workflows ===
EVM leg success = true
SVM leg success = true
final nonces  evm=1, svm=1
inference dispatch success = true
escrow release emitted 1 logs
escrow flag = 0x01
cross-VM transfer success = true
evm sender balance = 9000000000000000000
svm recipient balance = 1000000000000000000

All workflows finished.

If you have a Canton participant running on localhost:5001, the Canton workflow will exercise the live participant; otherwise it no-ops via tracing::warn! and the run still completes cleanly.

What You Learned

You now have a complete reference for building commerce workflows across all three Tenzro execution backends:

Next Steps

  • Compile real Solana BPF programs and install them to exercise solana_rbpf end-to-end
  • Spin up a local Canton participant via Docker to enable the Canton workflows
  • Continue to the Agentic Commerce Workflows tutorial for the identity / payments / settlement layer