Tenzro Testnet is live. Get testnet TNZO

DAML Executor

The Tenzro DAML executor provides integration with Canton nodes for executing Digital Asset Modeling Language (DAML) smart contracts. It uses tonic gRPC to communicate with Canton Ledger API v2, supporting command submission, contract queries, and DAR package uploads with lazy connection and graceful degradation.

Architecture

The DamlExecutor wraps a CantonClient that communicates with external Canton nodes via gRPC. When Canton is unreachable, the executor returns proper errors instead of fake data, enabling development and testing without requiring a running Canton instance.

┌────────────────────────────────┐ │ DamlExecutor │ │ (Canton Integration) │ └───────────┬────────────────────┘ │ │ wraps ▼ ┌────────────────────────────────┐ │ CantonClient │ │ (gRPC Client - Lazy) │ ├────────────────────────────────┤ │ • CommandService │ │ • StateService │ │ • PackageManagementService │ │ • PartyManagementService │ └───────────┬────────────────────┘ │ │ gRPC (tonic) ▼ ┌────────────────────────────────┐ │ Canton Node │ │ (Ledger API v2 Server) │ ├────────────────────────────────┤ │ • DAML interpreter │ │ • Contract storage │ │ • Participant node │ │ • Synchronization domains │ └────────────────────────────────┘

Canton Ledger API v2

The executor implements three primary Canton Ledger API v2 services using hand-crafted prost messages for maximum control over serialization and error handling.

CommandService: SubmitAndWait

Submit DAML commands (create, exercise, exerciseByKey) and wait for transaction commitment. Returns transaction ID on success or detailed error information on failure.

use tenzro_vm::daml::{DamlExecutor, DamlCommand, DamlValue}; use tenzro_types::DamlTemplateId; let executor = DamlExecutor::new("http://localhost:5011"); // Create a new contract let template_id = DamlTemplateId { package_id: "0a1b2c3d...".to_string(), module_name: "Main".to_string(), entity_name: "Asset".to_string(), }; let create_arguments = vec![ ("issuer", DamlValue::Party("Alice".to_string())), ("owner", DamlValue::Party("Bob".to_string())), ("name", DamlValue::Text("Gold Bar".to_string())), ("amount", DamlValue::Int64(100)), ]; let command = DamlCommand::Create { template_id: template_id.clone(), create_arguments, }; // Submit command to Canton match executor.submit_command("Alice", command).await { Ok(tx_id) => { println!("Contract created, transaction ID: {}", tx_id); } Err(e) => { // Canton unreachable or command rejected eprintln!("Command submission failed: {}", e); } } // Exercise a choice on existing contract let exercise_command = DamlCommand::Exercise { template_id, contract_id: "001122334455...".to_string(), choice: "Transfer".to_string(), choice_argument: DamlValue::Record(vec![ ("newOwner", DamlValue::Party("Charlie".to_string())), ]), }; let tx_id = executor.submit_command("Bob", exercise_command).await?; println!("Choice exercised: {}", tx_id);

StateService: GetActiveContracts

Query active contracts for a specific template, optionally filtered by party. Returns contract IDs and payloads for all matching contracts visible to the querying party.

use tenzro_vm::daml::ActiveContract; // Query all active contracts for a template let template_id = DamlTemplateId { package_id: "0a1b2c3d...".to_string(), module_name: "Main".to_string(), entity_name: "Asset".to_string(), }; match executor.get_active_contracts(&template_id, Some("Alice")).await { Ok(contracts) => { println!("Found {} active contracts", contracts.len()); for contract in contracts { println!("Contract ID: {}", contract.contract_id); println!("Created at: {:?}", contract.created_at); // Parse contract payload match &contract.payload { DamlValue::Record(fields) => { for (name, value) in fields { println!(" {}: {:?}", name, value); } } _ => {} } } } Err(e) => { // Canton unreachable eprintln!("Query failed: {}", e); // Executor returns VmError::CantonError instead of fake data } } // Filter by party and template let alice_contracts = executor .get_active_contracts(&template_id, Some("Alice")) .await?; let all_contracts = executor .get_active_contracts(&template_id, None) .await?;

PackageManagementService: UploadDarFile

Upload compiled DAML archives (.dar files) to Canton. The DAR contains compiled DAML templates, choices, and type definitions that become available for contract creation.

use std::fs; // Compile DAML to DAR // daml build --output my-model.dar let dar_bytes = fs::read("my-model.dar")?; match executor.upload_dar(dar_bytes).await { Ok(package_id) => { println!("DAR uploaded successfully"); println!("Package ID: {}", package_id); // Package is now available for use let template_id = DamlTemplateId { package_id: package_id.clone(), module_name: "MyModel".to_string(), entity_name: "MyTemplate".to_string(), }; // Create contracts from uploaded package let command = DamlCommand::Create { template_id, create_arguments: vec![ ("field1", DamlValue::Text("value".to_string())), ], }; executor.submit_command("Alice", command).await?; } Err(e) => { eprintln!("DAR upload failed: {}", e); } }

DAML Value Types

DAML supports rich value types including primitives, records, variants, lists, optional values, maps, and contract IDs. The executor serializes and deserializes these types for Canton API communication.

use tenzro_types::DamlValue; // Primitive types let text = DamlValue::Text("Hello".to_string()); let int64 = DamlValue::Int64(42); let decimal = DamlValue::Decimal("123.456".to_string()); let bool_val = DamlValue::Bool(true); let party = DamlValue::Party("Alice".to_string()); let date = DamlValue::Date(18_000); // Days since Unix epoch let timestamp = DamlValue::Timestamp(1_640_000_000_000_000); // Microseconds // Record (struct) let record = DamlValue::Record(vec![ ("name", DamlValue::Text("Asset".to_string())), ("quantity", DamlValue::Int64(100)), ("owner", DamlValue::Party("Bob".to_string())), ]); // Variant (enum) let variant = DamlValue::Variant { constructor: "Success".to_string(), value: Box::new(DamlValue::Text("OK".to_string())), }; // List let list = DamlValue::List(vec![ DamlValue::Int64(1), DamlValue::Int64(2), DamlValue::Int64(3), ]); // Optional let some_value = DamlValue::Optional(Some(Box::new(DamlValue::Int64(42)))); let none_value = DamlValue::Optional(None); // Contract ID let contract_id = DamlValue::ContractId("001122334455...".to_string()); // Map let map = DamlValue::Map(vec![ (DamlValue::Text("key1".to_string()), DamlValue::Int64(100)), (DamlValue::Text("key2".to_string()), DamlValue::Int64(200)), ]); // Nested structures let complex = DamlValue::Record(vec![ ("metadata", DamlValue::Record(vec![ ("version", DamlValue::Int64(1)), ("timestamp", DamlValue::Timestamp(1_640_000_000_000_000)), ])), ("data", DamlValue::List(vec![ DamlValue::Record(vec![ ("id", DamlValue::Int64(1)), ("name", DamlValue::Text("Item 1".to_string())), ]), ])), ]);

Lazy Connection

The Canton client uses lazy connection initialization. The gRPC channel is not established until the first API call, allowing the executor to be instantiated even when Canton is unreachable.

use tenzro_vm::daml::{DamlExecutor, CantonClient}; // Create executor (does not connect yet) let executor = DamlExecutor::new("http://localhost:5011"); // Connection attempt happens on first operation match executor.get_active_contracts(&template_id, None).await { Ok(contracts) => { // Canton is reachable, connection established println!("Connected to Canton, found {} contracts", contracts.len()); } Err(VmError::CantonError(msg)) => { // Canton unreachable or gRPC error println!("Canton connection failed: {}", msg); // Executor remains usable for other VM types } Err(e) => { // Other error (validation, etc.) eprintln!("Error: {}", e); } } // Subsequent operations reuse connection for _ in 0..10 { let contracts = executor.get_active_contracts(&template_id, None).await?; // Connection is cached and reused }

Graceful Degradation

When Canton is unreachable, the executor returns VmError::CantonError with descriptive error messages instead of fake success data. This enables proper error handling and testing without requiring a Canton instance for development.

use tenzro_vm::error::VmError; // Handle Canton unavailability gracefully async fn execute_daml_with_fallback( executor: &DamlExecutor, command: DamlCommand, ) -> Result<String, VmError> { match executor.submit_command("Alice", command.clone()).await { Ok(tx_id) => { // Canton executed command successfully Ok(tx_id) } Err(VmError::CantonError(msg)) if msg.contains("connection refused") => { // Canton is down, log and return error warn!("Canton unavailable: {}", msg); Err(VmError::CantonError( "Canton node not reachable. DAML execution requires a running Canton instance.".to_string() )) } Err(VmError::CantonError(msg)) if msg.contains("INVALID_ARGUMENT") => { // Canton rejected command (bad data, invalid party, etc.) error!("Canton rejected command: {}", msg); Err(VmError::ExecutionFailed(format!("Invalid command: {}", msg))) } Err(e) => Err(e), } } // Integration tests can verify error handling #[tokio::test] async fn test_canton_unreachable() { let executor = DamlExecutor::new("http://localhost:9999"); // Invalid endpoint let result = executor.get_active_contracts(&template_id, None).await; assert!(matches!(result, Err(VmError::CantonError(_)))); }

Transaction Structure

Tenzro transactions targeting DAML execution include a vm_type: VmType::Daml field and encode the DAML command in the transaction data field as JSON.

use tenzro_types::{Transaction, VmType}; use serde_json; // Create DAML command let command = DamlCommand::Create { template_id: DamlTemplateId { package_id: "abc123...".to_string(), module_name: "Main".to_string(), entity_name: "Asset".to_string(), }, create_arguments: vec![ ("owner", DamlValue::Party("Alice".to_string())), ("value", DamlValue::Int64(1000)), ], }; // Encode as transaction let tx = Transaction { vm_type: VmType::Daml, from: sender_address, to: None, // DAML transactions don't have explicit 'to' address data: serde_json::to_vec(&command)?, gas_limit: 50_000, // Estimated gas for DAML execution gas_price: 10_000_000_000, nonce: 0, chain_id: 1337, ..Default::default() }; // Submit to multi-VM runtime let result = runtime.execute_transaction(&tx).await?; // Extract transaction ID from output let tx_id = String::from_utf8(result.output)?; println!("DAML transaction ID: {}", tx_id);

Party Management

DAML uses parties as identity primitives. Parties must be allocated on the Canton participant before they can submit commands or query contracts. The executor provides party allocation via PartyManagementService.

use tenzro_vm::daml::party::PartyDetails; // Allocate a new party let party_id = executor.allocate_party("Alice", "Alice's participant").await?; println!("Allocated party: {}", party_id); // List all parties let parties = executor.list_known_parties().await?; for party in parties { println!("Party: {}", party.party_id); println!(" Display name: {}", party.display_name); println!(" Local: {}", party.is_local); } // Use allocated party for commands let command = DamlCommand::Create { template_id: template_id.clone(), create_arguments: vec![ ("issuer", DamlValue::Party(party_id.clone())), ("amount", DamlValue::Int64(500)), ], }; executor.submit_command(&party_id, command).await?;

Gas Estimation

DAML execution gas is estimated based on command complexity. Create commands consume less gas than exercise commands, and complex choice arguments increase gas consumption.

OperationBase GasVariable Cost
Create Contract20,000+100 per argument field
Exercise Choice30,000+200 per choice field
Exercise By Key35,000+200 per key field
Query Contracts10,000+50 per contract returned
Upload DAR100,000+1 per byte
use tenzro_vm::daml::gas::estimate_daml_gas; let command = DamlCommand::Create { template_id: template_id.clone(), create_arguments: vec![ ("field1", DamlValue::Text("value".to_string())), ("field2", DamlValue::Int64(42)), ("field3", DamlValue::Party("Alice".to_string())), ], }; let estimated_gas = estimate_daml_gas(&command); // 20,000 base + (3 fields * 100) = 20,300 println!("Estimated gas: {}", estimated_gas);

Canton Connection Configuration

use tenzro_vm::daml::{DamlExecutor, DamlConfig}; let config = DamlConfig { canton_endpoint: "http://localhost:5011".to_string(), max_command_size: 1_048_576, // 1 MB timeout_seconds: 30, retry_attempts: 3, enable_tls: false, tls_ca_cert_path: None, }; let executor = DamlExecutor::with_config(config, state_adapter); // Override timeout for specific operations executor.set_timeout(60); // 60 seconds for long-running operations

Error Handling

The executor maps Canton gRPC status codes to semantic errors. Connection failures, invalid arguments, permission errors, and contract not found errors are all represented as distinct error variants.

use tonic::Code; match executor.submit_command("Alice", command).await { Ok(tx_id) => { println!("Success: {}", tx_id); } Err(VmError::CantonError(msg)) => { // Parse gRPC status code if msg.contains("UNAVAILABLE") { eprintln!("Canton node is not reachable"); } else if msg.contains("INVALID_ARGUMENT") { eprintln!("Command validation failed: {}", msg); } else if msg.contains("PERMISSION_DENIED") { eprintln!("Party not authorized for this operation"); } else if msg.contains("NOT_FOUND") { eprintln!("Contract not found or already archived"); } else if msg.contains("ALREADY_EXISTS") { eprintln!("Contract with this key already exists"); } else { eprintln!("Canton error: {}", msg); } } Err(e) => { eprintln!("Execution error: {}", e); } }

Canton Ledger API Services

ServiceMethodsStatus
CommandServiceSubmitAndWait, SubmitAndWaitForTransactionImplemented
StateServiceGetActiveContracts, GetTransactionsPartial (GetActiveContracts only)
PackageManagementServiceUploadDarFile, ListKnownPackagesPartial (UploadDarFile only)
PartyManagementServiceAllocateParty, ListKnownPartiesNot implemented
VersionServiceGetLedgerApiVersionNot implemented

Testing

#[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_canton_unavailable() { let executor = DamlExecutor::new("http://localhost:9999"); let result = executor.get_active_contracts(&template_id, None).await; assert!(matches!(result, Err(VmError::CantonError(_)))); } #[tokio::test] async fn test_command_submission() { // Requires running Canton node let executor = DamlExecutor::new("http://localhost:5011"); let command = DamlCommand::Create { template_id: test_template_id(), create_arguments: vec![ ("owner", DamlValue::Party("Alice".to_string())), ], }; let tx_id = executor.submit_command("Alice", command).await?; assert!(!tx_id.is_empty()); } #[tokio::test] async fn test_graceful_degradation() { // Test that missing Canton returns error, not fake data } }

Canton Setup

# Install Canton (requires Java 11+) wget https://github.com/digital-asset/canton/releases/download/v2.8.0/canton-open-source-2.8.0.tar.gz tar xzf canton-open-source-2.8.0.tar.gz # Run Canton with simple topology cd canton-open-source-2.8.0 bin/canton -c examples/01-simple-topology/simple-topology.conf # Canton starts with: # - Participant node on port 5011 (Ledger API) # - Admin API on port 5012 # - Domain node for synchronization # Allocate parties canton> participant1.parties.enable("Alice") canton> participant1.parties.enable("Bob") # Upload DAR canton> participant1.dars.upload("path/to/my-model.dar") # Canton is now ready for Tenzro executor connections

DAML Smart Contract Example

-- File: daml/Main.daml module Main where template Asset with issuer : Party owner : Party name : Text amount : Int where signatory issuer observer owner choice Transfer : ContractId Asset with newOwner : Party controller owner do create this with owner = newOwner choice Split : (ContractId Asset, ContractId Asset) with splitAmount : Int controller owner do assertMsg "Split amount must be positive" (splitAmount > 0) assertMsg "Split amount cannot exceed total" (splitAmount < amount) asset1 <- create this with amount = splitAmount asset2 <- create this with amount = amount - splitAmount return (asset1, asset2) -- Compile: daml build --output asset-model.dar

Production Readiness

Production-Ready:

  • Real tonic gRPC integration with Canton Ledger API v2
  • Hand-crafted prost messages for CommandService, StateService, PackageManagementService
  • Lazy connection with graceful degradation
  • Proper error handling (VmError::CantonError when unreachable)
  • Support for all DAML value types (primitives, records, variants, lists, maps)
  • Command submission (Create, Exercise, ExerciseByKey)
  • Contract queries (GetActiveContracts)
  • DAR package uploads

Outstanding Issues:

  • Party management service not implemented
  • GetTransactions not implemented
  • ListKnownPackages not implemented
  • State adapter does not persist Canton contract IDs (issue #25)
  • No transaction signature verification (issue #26)

Canton Integration Benefits

Integrating Canton DAML execution into Tenzro provides:

  • Enterprise-grade smart contracts with formal verification and privacy-preserving execution
  • Multi-party workflows where different participants run their own nodes but share contract state
  • Sub-transaction privacy where only authorized parties see contract details
  • Composability with EVM and SVM contracts via cross-VM calls
  • Canton network interop enabling Tenzro to bridge to existing Canton deployments in finance and enterprise