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.
| Operation | Base Gas | Variable Cost |
|---|
| Create Contract | 20,000 | +100 per argument field |
| Exercise Choice | 30,000 | +200 per choice field |
| Exercise By Key | 35,000 | +200 per key field |
| Query Contracts | 10,000 | +50 per contract returned |
| Upload DAR | 100,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
| Service | Methods | Status |
|---|
| CommandService | SubmitAndWait, SubmitAndWaitForTransaction | Implemented |
| StateService | GetActiveContracts, GetTransactions | Partial (GetActiveContracts only) |
| PackageManagementService | UploadDarFile, ListKnownPackages | Partial (UploadDarFile only) |
| PartyManagementService | AllocateParty, ListKnownParties | Not implemented |
| VersionService | GetLedgerApiVersion | Not 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