Build Multi-VM Commerce Workflows
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:
- Construct real
EvmExecutor,SvmExecutor, andDamlExecutorinstances - Drive bytecode through
execute_with_state_adapterorexecute_transactiononMultiVmRuntime - Observe state mutations across engines: contract addresses, storage slots, logs, nonces
- Compose cross-VM flows where one backend's output feeds into another
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.rsStart 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:
0x1001— TNZO_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.0x1002— TOKEN_FACTORY: Create new ERC-20 tokens and register them in the unifiedTokenRegistry.0x1003— CROSS_VM_BRIDGE: Atomic cross-VM token transfer. Move TNZO between EVM, SVM, and Canton addresses in a single state transition.0x1004— STAKING: Stake or unstake TNZO directly from EVM contracts or EOAs.0x1005— GOVERNANCE: 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_workflowsYou'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:
- EVM — deploy via revm, pre-install runtimes, observe
logs/get_storage/get_nonce - SVM — dispatch through
MultiVmRuntime::execute_transaction, pre-install program bytes at the target address - Canton/DAML — encode
DamlCommandvia serde_json, gate onis_canton_connectedfor portability - Cross-VM — share a single
StateAdapteracross backends to compose multi-engine flows - Cross-VM token architecture — wrap native TNZO via the
TNZO_BRIDGEprecompile, transfer across VMs viaCROSS_VM_BRIDGE, and leverage the Sei V2 pointer model where EVM, SVM, and Canton share one underlying balance with no bridge risk
Next Steps
- Compile real Solana BPF programs and install them to exercise
solana_rbpfend-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