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)

CIP-56 TNZO Holding

Canton participants interact with native TNZO through a CIP-56 DAML token template that follows Canton's two-step transfer model. Like the EVM wTNZO pointer contract and SVM SPL adapter, the CIP-56 holding maps directly to the underlying native TNZO balance via the TnzoToken layer — no bridge risk, no separate liquidity.

Canton transfers use a two-step flow: the sender creates a transfer instruction, and the recipient either accepts or rejects it. This preserves Canton's privacy guarantees — only the involved parties see the transfer details.

// CIP-56 two-step transfer flow
//
// Step 1: Sender creates a transfer instruction
// Step 2: Recipient accepts or rejects

// DAML template (conceptual):
//
// template TnzoHolding
//   with
//     owner : Party
//     amount : Decimal
//   where
//     signatory owner
//
//     choice Transfer : ContractId TransferInstruction
//       with
//         recipient : Party
//         transferAmount : Decimal
//       controller owner
//       do
//         create TransferInstruction with
//           sender = owner
//           recipient
//           amount = transferAmount
//
// template TransferInstruction
//   with
//     sender : Party
//     recipient : Party
//     amount : Decimal
//   where
//     signatory sender
//     observer recipient
//
//     choice Accept : ContractId TnzoHolding
//       controller recipient
//     choice Reject : ()
//       controller recipient

// Tenzro maps Canton parties to addresses:
use tenzro_vm::daml::cip56;

// Create transfer instruction (sender side)
let instruction = cip56::create_transfer_instruction(
    sender_party,    // "Alice"
    recipient_party, // "Bob"
    "100.0",         // DAML Decimal string
)?;

// Accept transfer (recipient side)
let holding = cip56::accept_transfer(instruction_contract_id)?;

// Reject transfer (recipient side)
cip56::reject_transfer(instruction_contract_id)?;

Decimal Formatting

DAML uses arbitrary-precision Decimal strings. The CIP-56 adapter converts native TNZO amounts (u128 with 18 decimals) to DAML Decimal strings and back, preserving full precision.

// Native TNZO (18 decimals) → DAML Decimal string
// 1_000_000_000_000_000_000 → "1.000000000000000000"
// 500_000_000_000_000      → "0.000500000000000000"

// DAML Decimal string → Native TNZO
// "1.5" → 1_500_000_000_000_000_000

// Party-to-address mapping
// Canton parties are mapped to Tenzro addresses via SHA-256:
// address = SHA-256("canton:party:" || party_name)[12..32]

Pointer model: Like the EVM wTNZO pointer contract and SVM SPL adapter, the CIP-56 DAML holding shares the same underlying native TNZO balance. There is no locked collateral, no mint/burn asymmetry, and no separate liquidity pool. A transfer on Canton is immediately reflected in EVM and SVM balances. See the Cross-VM Tokens documentation for the full cross-VM architecture.

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