Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Canton Rust SDK

An async Rust SDK for the Canton Network Ledger API. It covers:

  • gRPC and JSON transports. It talks Ledger API v2 over gRPC and over JSON (HTTP and WebSocket), with the same core operations on both. Interactive submission, health checks and explicit paging are gRPC-only.
  • Type-safe codegen from DARs. Daml templates become Rust types.
  • The token standard, CIP-56 and CIP-0112.
  • Interactive submission with a pluggable signer.
  • A typed client for the Participant Query Store.

The SDK is open source under Apache-2.0. Its development is funded by the Canton Dev Fund (proposal #407).

cargo add canton

What is in it

canton is a thin facade over the family, so one dependency gets all of it at one version. The crates behind it can also be used one at a time:

CrateFor
canton-ledgerThe Ledger API client:
• submit, recover an ambiguous submission
• read the active contract set
• stream updates, resumably
• point reads
• interactive submission
canton-authOIDC client-credentials tokens, cached and refreshed, with Keycloak, Auth0 and Okta presets.
canton-damlThe runtime under generated bindings:
• Daml types, Template and Choice
• command builders
• the JSON and gRPC value codecs
canton-codegen-clidpm-codegen-rust: turns a DAR into a typed bindings crate.
canton-tokenToken-standard workflows over the registry API: transfers, allocations, settlement.
canton-signerThe Signer trait for external signing, with an Ed25519 implementation.
canton-adminParties, packages and users over the Ledger API’s admin services, and topology reads over the Canton admin API.
canton-pqsTyped queries against a PQS (Scribe) store.
canton-splice-*Pre-built bindings for the Splice and token-standard DARs.

The full list, including the internal crates, is in the README.

Where to start

The API reference for every crate is on docs.rs.

Status

Releases are on crates.io. Before 1.0, breaking changes go into a new minor version and are listed in the changelog. The changes already decided for 1.0 are recorded in ADR-0010.

Bugs and questions go to GitHub issues. Vulnerabilities go privately, as the security policy describes.

Getting started

This page takes you from an empty crate to a submitted command, and to recovering a submission whose response was lost. You need a participant: Local development and testing shows how to start a LocalNet. The snippets assume its App Provider participant, with the gRPC Ledger API on http://localhost:3901 and the JSON Ledger API on http://localhost:3975.

Add the SDK

The canton crate gives you the whole SDK as one dependency at one version:

cargo add canton
cargo add tokio --features macros,rt-multi-thread

canton contains only re-exports (ADR-0007). Each member crate is a module: canton::ledger is canton-ledger, and likewise auth, admin, daml, signer, token and pqs. The shared canton-core types (Config, Error, Result, RetryConfig, TlsConfig, TokenSource, ErrorCategory) are at the root, next to canton::localnet and canton::telemetry. The code generator runs ahead of time, not in your program (you commit what it writes), so it is not re-exported. See Typed bindings from a DAR.

If you only need part of the SDK, depend on the member crates directly:

cargo add canton-ledger canton-auth

All canton-* crates are released together, so use equal versions of them (ADR-0005). The snippets here use facade paths (canton::ledger::…), and the included examples use the member crates (canton_ledger::…). Both name the same items.

The MSRV is Rust 1.88, and the build vendors protoc, so you need no system install. Supported Canton and Daml-LF versions are in the compatibility matrix.

Feature flags

FeatureCrateWhat it adds
wscanton-ledgerWebSocket streaming for the JSON transport (ws_updates, ws_active_contracts, ws_completions, ws_updates_resumable). You also need it to recover a JSON submission.
otelcanton-core, canton-ledgerOTLP span export (telemetry::otel::otlp_tracer), and W3C trace-context injection into outgoing gRPC metadata and JSON headers.
ed25519canton-signerThe in-memory Ed25519 key (Ed25519Key), built on ring. On by default through the facade.
tlscanton-pqsConnecting to a PQS store over TLS (PqsClient::connect_tls).

The facade forwards all four, and names the PQS one pqs-tls: canton = { version = "0.3", features = ["ws", "otel", "pqs-tls"] }. An HSM or KMS deployment that must not hold a private key in process memory sets default-features = false, which removes the in-memory Ed25519Key, and implements Signer itself. (ring stays in the dependency tree either way: the TLS stack uses it.) See Interactive submission and external signing.

Connect over gRPC

CantonClient is the gRPC client. You build it from a Config. connect_lazy returns immediately and opens the connection on the first RPC, so a participant that is down fails the call, not the construction. An invalid endpoint URI returns Error::InvalidRequest. Clones of a client share one connection and are cheap, so give each task its own.

This example prints the participant’s Ledger API version and health:

//! Minimal quickstart: connect to a participant's gRPC Ledger API and print its
//! version and health. No authentication required.
//!
//! Run against LocalNet's App Provider participant:
//!   CANTON_ENDPOINT=http://localhost:3901 \
//!     cargo run -p canton-ledger --example version_and_health
//!
//! `CANTON_TEST_ENDPOINT` (the live tests' name) works too, and the default is
//! `http://localhost:3901`, so on LocalNet neither is needed.

use canton_ledger::{CantonClient, Config};

#[tokio::main]
async fn main() -> canton_ledger::Result<()> {
    let endpoint = std::env::var("CANTON_ENDPOINT")
        .or_else(|_| std::env::var("CANTON_TEST_ENDPOINT"))
        .unwrap_or_else(|_| "http://localhost:3901".to_string());

    // `connect_lazy` returns immediately; the connection opens on the first RPC.
    let client = CantonClient::connect_lazy(Config::new(endpoint))?;

    println!("ledger api version: {}", client.version().await?);
    println!("node health:        {:?}", client.health_check().await?);
    Ok(())
}

Run it from a checkout of the repository with cargo run -p canton-ledger --example version_and_health. health_check calls the standard grpc.health.v1.Health service: a healthy participant answers ServingStatus::Serving, and an unreachable one returns a transport error.

Config::new starts with no authentication, no retries and a 30-second per-request timeout. These builder methods change that:

MethodEffect
with_token(token)A fixed bearer token. See Authenticate.
with_oidc(source)A token source that refreshes, such as canton::auth::TokenProvider
with_tls(TlsConfig)TLS or mutual TLS
with_timeout(duration)The per-request timeout. Long submitAndWait calls under load may need more than 30 s.
with_retry(RetryConfig)Retries for unary calls. See Errors and retries.
with_max_decoding_message_size(bytes)The largest response the client decodes. The default is 128 MiB.

An https:// endpoint uses TLS with the platform’s root certificates. For a private CA, a different verified name or a client certificate, pass a TlsConfig built with with_ca_certificate, with_domain_name or with_client_identity. With with_tls, an http:// endpoint is upgraded to https://, so the connection is never silently plaintext.

On a LocalNet started with canton-devkit, Config::from_env() reads the exported endpoint and token. See Local development and testing.

Connect over JSON

JsonClient talks to the JSON Ledger API over HTTP, with the same core operations. Its builders are the counterparts of Config’s: with_token, with_oidc, with_retry, with_timeout (30 s by default), with_max_decoding_message_size, and with_tls, which takes a &TlsConfig, returns a Result, and goes last.

use canton::ledger::JsonClient;

let json = JsonClient::new("http://localhost:3975").with_token(token);
println!("ledger api version: {}", json.version().await?);
println!("ledger end:         {}", json.ledger_end().await?);

health_check is gRPC-only. WebSocket streaming and JSON recovery need the ws feature. JsonClient::from_env() is the counterpart of Config::from_env().

Authenticate

A client sends no credentials (the default), a fixed bearer token, or a token from a source that refreshes it.

Fixed token. Config::with_token and JsonClient::with_token send the token as is and never refresh it. Use them for a LocalNet’s development tokens, or for a token your application already manages.

OIDC client credentials. canton-auth implements the OAuth2 client-credentials grant. TokenProvider fetches a token, caches it, and refreshes it 30 seconds before it expires. While a refresh is in flight, other callers wait for that one fetch, which times out after 30 seconds. Clones share the cache, so one provider can back a gRPC client and a JSON client:

use canton::auth::{OidcConfig, TokenProvider};
use canton::ledger::{CantonClient, Config, JsonClient};

let provider = TokenProvider::new(OidcConfig::keycloak(
    "http://keycloak.localhost:8082", "AppProvider", "app-provider-backend", client_secret,
));
let grpc = CantonClient::connect_lazy(
    Config::new("http://localhost:3901").with_oidc(provider.clone()),
)?;
let json = JsonClient::new("http://localhost:3975").with_oidc(provider);

OidcConfig takes a token endpoint URL directly. The presets build the URL for you and make each provider’s usual token request:

ConstructorToken endpointNotes
OidcConfig::new(token_url, client_id, secret)token_urlSends the credentials in the request body
OidcConfig::keycloak(base_url, realm, client_id, secret){base_url}/realms/{realm}/protocol/openid-connect/tokenThe LocalNet IdP
OidcConfig::auth0(domain, audience, client_id, secret)https://{domain}/oauth/tokenSends audience, which Auth0 needs to issue a token the participant can verify
OidcConfig::okta(domain, auth_server, client_id, secret)https://{domain}/oauth2/{auth_server}/v1/tokenSends HTTP Basic credentials, Okta’s default

To adjust a configuration, use with_scope, with_audience and with_client_auth(ClientAuth::Basic) or with_client_auth(ClientAuth::Post).

Rejected credentials (HTTP 401 or 403) return Error::Auth, which is not retriable. An unreachable or slow endpoint returns Error::Connection or Error::Timeout, which are. The SDK does not follow a redirect from the token endpoint, because that would send the client secret elsewhere. If a participant rejects a token, TokenProvider::invalidate() makes the next call fetch a new one. Debug output never shows the client secret or a Config’s tokens.

Your own token source. Config::with_oidc accepts any TokenSource. The trait has one method, fetch_bearer, which returns the current token, or None for an unauthenticated call. Implement it to get tokens from a vault or a sidecar. JsonClient::with_oidc only takes a TokenProvider.

Submit a command and read it back

Without generated bindings, you build commands from the Ledger API’s value types. Submit names the acting party, create and exercise build the commands, identifier(package, module, entity) names the template, and record and the value constructors build the arguments.

This example authenticates with OIDC, creates an AppInstallRequest from the cn-quickstart licensing package, and prints the committed transaction:

//! Quickstart with OIDC auth: submit an `AppInstallRequest` create and read the
//! resulting transaction back.
//!
//! Reads the same variables as the live tests, so one export set runs both:
//!   CANTON_TEST_ENDPOINT=http://localhost:3901 \
//!   CANTON_TEST_TOKEN_URL=http://keycloak.localhost:8082/realms/AppProvider/protocol/openid-connect/token \
//!   CANTON_TEST_CLIENT_ID=app-provider-backend CANTON_TEST_CLIENT_SECRET=… \
//!   CANTON_TEST_PARTY=… CANTON_TEST_LICENSING_PKG='#quickstart-licensing' \
//!     cargo run -p canton-ledger --example submit_and_read
//!
//! The unprefixed spellings (`CANTON_ENDPOINT`, …) are accepted too and win
//! when both are set.

use canton_auth::{OidcConfig, TokenProvider};
use canton_ledger::{CantonClient, Config, Error, Submit, create, identifier, record, value};

/// A setting, from `CANTON_<NAME>` or the live tests' `CANTON_TEST_<NAME>`.
/// Two spellings for one value is a papercut for anyone following the README,
/// so the example answers to both rather than making them guess which.
fn env(name: &str) -> canton_ledger::Result<String> {
    let plain = format!("CANTON_{name}");
    let test = format!("CANTON_TEST_{name}");
    std::env::var(&plain)
        .or_else(|_| std::env::var(&test))
        .map_err(|_| Error::InvalidRequest(format!("set {plain} (or {test})")))
}

#[tokio::main]
async fn main() -> canton_ledger::Result<()> {
    let oidc = OidcConfig::new(env("TOKEN_URL")?, env("CLIENT_ID")?, env("CLIENT_SECRET")?);
    let party = env("PARTY")?;
    let pkg = env("LICENSING_PKG")?;

    let client = CantonClient::connect_lazy(
        Config::new(env("ENDPOINT")?).with_oidc(TokenProvider::new(oidc)),
    )?;

    // Build an AppInstallRequest create acting as `party`.
    let arguments = record(vec![
        ("provider", value::party(&party)),
        ("user", value::party(&party)),
        (
            "meta",
            value::record(record(vec![("values", value::empty_text_map())])),
        ),
    ]);
    let command = create(
        identifier(&pkg, "Licensing.AppInstall", "AppInstallRequest"),
        arguments,
    );

    let transaction = client
        .submit_and_wait_for_transaction(Submit::new(&party).add_command(command))
        .await?;

    println!(
        "committed transaction {} at offset {} with {} event(s)",
        transaction.update_id,
        transaction.offset,
        transaction.events.len()
    );
    Ok(())
}

The example reads ENDPOINT, TOKEN_URL, CLIENT_ID, CLIENT_SECRET, PARTY and LICENSING_PKG, each prefixed CANTON_ or CANTON_TEST_. Run it with cargo run -p canton-ledger --example submit_and_read. The package can be given by id, or by # and its name, as in #quickstart-licensing.

There are three ways to submit. Each waits for something different:

MethodWaits forReturns
submitNothingThe command id, to find the completion with later
submit_and_waitThe completionupdate_id and completion_offset
submit_and_wait_for_transactionThe transactionThe transaction, with update_id, offset and events

The transaction is filtered to the submission’s act_as and read_as parties, so your created events are in it. If you do not set a command id, the SDK generates a UUID. You can set your own with Submit::with_command_id and the user with with_user_id, and Submit::new_multi acts as several parties.

To read a transaction again later, use its update id (or its offset, with update_by_offset). You get Ok(None) if the given parties cannot see it:

use canton::ledger::proto::get_update_response::Update;

if let Some(Update::Transaction(tx)) = client.update_by_id(&update_id, vec![party.clone()]).await? {
    println!("{} events at offset {}", tx.events.len(), tx.offset);
}

Over JSON, JsonCommands takes the place of Submit, and you pass the arguments as Daml-LF JSON. Your crate needs serde_json for that:

use canton::ledger::JsonCommands;

let commands = JsonCommands::new(vec![party.clone()]).add_create(
    format!("{pkg}:Licensing.AppInstall:AppInstallRequest"),
    serde_json::json!({ "provider": party, "user": party, "meta": { "values": {} } }),
);
let response = json.submit_and_wait_for_transaction(&commands).await?;
println!("committed {} at offset {}", response.transaction.update_id, response.transaction.offset);

To use your own templates without hand-written records, generate typed bindings from the DAR. See Typed bindings from a DAR.

Recover an ambiguous submission

An error from a submission does not mean the command failed. After a dropped connection, a timeout, or a retry the participant de-duplicated, the command may have committed. The way to find out is the command’s change ID: the user, the acting parties and the command id. Canton de-duplicates on all three, because a command id alone is not unique across a participant’s users.

So fix the change ID before you send. CantonClient::submission returns a Submission handle that holds the change ID. If the send fails, recover reads the outcome from the completion stream:

use std::time::Duration;

// Take the offset before submitting: a completion that has gone past cannot be read again.
let offset = client.ledger_end().await?;
let submission = client.submission(Submit::new(&party).add_command(command));

if submission.submit_and_wait().await.is_err() {
    let completion = submission.recover(offset, Duration::from_secs(30)).await?;
    println!("committed after all: {}", completion.update_id);
}

If the ledger rejected the command, recover returns Error::CommandRejected. If no completion arrives in time, it returns Error::Timeout. A timeout is the correct answer for a command that never reached the participant.

The recover_a_submission example sends the same change ID twice on purpose. The participant rejects the second send as a duplicate, and the example recovers the first one’s outcome:

    // Where to read completions from, taken *before* submitting: a completion
    // that has already gone past cannot be read again.
    let offset = client.ledger_end().await?;

    let submission = client.submission(Submit::new(&party).add_command(command));
    let change_id = submission.change_id().clone();
    println!(
        "change id: command={} parties={:?}",
        change_id.command_id(),
        change_id.act_as()
    );

    let first = submission.submit_and_wait().await?;
    println!("committed: update_id={}", first.update_id);

    // The same submission again — what the SDK itself sends when a response is
    // lost and the request is retried. The participant de-duplicates on the
    // change ID, so this is rejected even though nothing is wrong.
    match submission.submit_and_wait().await {
        Ok(_) => println!("second submission accepted (no de-duplication window)"),
        Err(error) => {
            println!("second submission rejected as expected: {error}");

            // This is the moment the handle exists for. The match is on the
            // whole change ID — user, acting parties, command id — because a
            // command id alone is not unique across a participant's users.
            let completion = submission.recover(offset, Duration::from_secs(30)).await?;
            println!(
                "recovered the original outcome: update_id={} offset={}",
                completion.update_id, completion.offset
            );
            assert_eq!(
                completion.update_id, first.update_id,
                "recovery must find the command that actually committed"
            );
        }
    }

Set LEDGER_PARTY (and LEDGER_TOKEN if needed), then run cargo run -p canton-ledger --example recover_a_submission. LEDGER_ENDPOINT defaults to http://localhost:3901 and LEDGER_PKG to #quickstart-licensing.

Over JSON, JsonClient::submission returns a JsonSubmission, whose recover reads completions over the WebSocket (feature ws) and returns the completion as raw JSON.

Errors and retries

Every crate returns one error type, canton_core::Error (canton::Error, with the canton::Result alias). It is #[non_exhaustive], so a match on it needs a wildcard arm.

Error::is_retriable says whether trying again can help:

  • Timeout, Transport and Connection are retriable.
  • For a gRPC Status or a JSON Http error, Canton’s ErrorCategory decides. Without a category, the gRPC codes Unavailable, DeadlineExceeded, ResourceExhausted and Aborted are retriable, as is a call whose connection dropped mid-flight (an Unknown or Internal status that tonic built from a transport error), and so are HTTP 408, 429 and 5xx.
  • Nothing else is retriable, including Auth, InvalidRequest, CommandRejected, Json, Payload, not-found and already-exists.

These accessors work on both transports, so you never match on message text:

fn explain(error: &canton::Error) {
    eprintln!(
        "{error}: retriable={} category={:?} reason={:?} correlation_id={:?}",
        error.is_retriable(),
        error.category(),
        error.error_info().map(|info| info.reason), // e.g. DUPLICATE_COMMAND
        error.correlation_id(),                     // quote it to the participant's operator
    );
}

Also available: retry_delay(), resource_info() (the contract, party or package concerned), code() (the gRPC code, on either transport) and pruned_earliest_offset().

Retries are off by default. Turn them on with Config::with_retry or JsonClient::with_retry:

use std::time::Duration;
use canton::{Config, RetryConfig};

let config = Config::new("http://localhost:3901").with_retry(
    RetryConfig::default() // 3 attempts, 100 ms initial backoff, 5 s cap
        .with_max_attempts(5)
        .with_attempt_timeout(Duration::from_secs(20)),
);

The SDK retries unary calls on retriable errors only, with jittered exponential backoff, and waits longer if the participant recommends it (up to 60 seconds). A retried submission keeps its command id, so the participant de-duplicates it. If submit sees its own retry rejected as a duplicate, it reports success. submit_and_wait and submit_and_wait_for_transaction cannot, because a de-duplicated retry carries no result: the rejection reaches you, and Submission::recover gets the outcome.

Streams are not covered by this policy. The resumable stream variants, such as updates_resumable, reconnect on their own from the last offset they saw. See Reading the ledger.

Next steps

Local development and testing

This page covers two things: pointing your own program at a Canton network on your machine, and running the SDK’s own test suites, with or without one.

Choosing a local network

Any participant the compatibility matrix covers will do. These are the usual ways to run one locally, least setup first:

ToolStart itApp Provider participantCredentials
canton-devkitcanton-devkit localnet up demo (or dpm localnet up demo)its own ports; read them from the export, or choose them with --port-baseSplice’s unsafe-jwt-hmac-256; tokens are exported ready-made
Canton Builder Toolcanton builder start; canton builder status prints the ports3901 gRPC, 3902 admin, 3975 JSONcanton builder token --validator app-provider prints a bearer token whose user carries ParticipantAdmin
Splice LocalNetDocker Compose3901 gRPC, 3902 admin, 3975 JSONnone by default; the other profile, unsafe-jwt-hmac-256, has you sign tokens yourself
cn-quickstartmake setup && make build && make start3901 gRPC, 3902 admin, 3975 JSONKeycloak: OIDC client credentials

canton-devkit exports the network into your shell under the names the SDK reads; the next section relies on that. cn-quickstart ships the licensing app the SDK’s command-submission tests use, and you can upload its DAR to the others (Packages for the live suites). More tooling is listed on the Canton Dev Hub.

A LocalNet with no configuration in your program

canton-devkit runs a Splice LocalNet (two participants and a super-validator) and prints its endpoints, tokens and party ids as shell variables. The SDK reads those variables directly, so nothing in your program names a host, a port or a credential:

canton-devkit localnet up demo            # or: dpm localnet up demo
eval "$(canton-devkit localnet env demo)"
use canton::ledger::{CantonClient, JsonClient};
use canton::{Config, localnet};

let grpc = CantonClient::connect_lazy(Config::from_env()?)?; // app-provider, with its token
let json = JsonClient::from_env()?;                          // the same participant over JSON
let user = Config::from_env_for("app-user")?;                // the other participant
let party = localnet::party("app-provider");                 // Option<String>, for act_as

The runnable version is the localnet example:

cargo run -p canton-ledger --example localnet
use canton_core::{Config, localnet};
use canton_ledger::{CantonClient, JsonClient};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    if let (Some(instance), Some(version)) = (localnet::instance(), localnet::splice_version()) {
        println!("network `{instance}`, Splice {version}");
    }

    // The gRPC lane. One line, and the vhost hostname and scheme-less
    // `host:port` the exporter emits are handled on the way through.
    let grpc = CantonClient::connect_lazy(Config::from_env()?)?;
    println!(
        "gRPC   {}  ->  Ledger API {}",
        endpoint()?,
        grpc.version().await?
    );

    // The JSON lane reads the same environment.
    let json = JsonClient::from_env()?;
    println!("JSON   ->  Ledger API {}", json.version().await?);

    // Both participants are exported, so a test can drive two sides of a
    // workflow without a second configuration file.
    match Config::from_env_for("app-user") {
        Ok(config) => {
            let user = CantonClient::connect_lazy(config)?;
            println!("app-user  ->  Ledger API {}", user.version().await?);
        }
        Err(_) => println!("app-user  ->  not exported by this network"),
    }

    // Party ids, which is what an application actually needs to submit. These
    // are the on-ledger ids, not the Ledger API user names.
    for alias in ["app-provider", "app-user", "sv"] {
        match localnet::party(alias) {
            Some(party) => println!("party {alias:<13} {party}"),
            None => println!("party {alias:<13} (not resolved yet)"),
        }
    }

    let offset = grpc.ledger_end().await?;
    println!("ledger end at offset {offset}");
    Ok(())
}

/// Only for the log line above — the client does not need to be asked.
fn endpoint() -> Result<String, canton_core::Error> {
    localnet::grpc_endpoint(None).ok_or_else(|| {
        canton_core::Error::InvalidRequest("no endpoint in the environment".to_string())
    })
}

The variables

canton_core::localnet is the one place in the SDK that knows these names. The canton facade re-exports it as canton::localnet.

VariableMeaningRead by
CANTON_GRPC_LEDGER_API_URLgRPC Ledger API, scheme-less host:portConfig::from_env, localnet::grpc_endpoint(None)
CANTON_JSON_LEDGER_API_URLJSON Ledger API, with an http(s):// schemeJsonClient::from_env, localnet::json_endpoint(None)
CANTON_<ROLE>_JWTthat role’s bearer tokenboth of the above, localnet::token
CANTON_<ALIAS>_PARTYan on-ledger party idlocalnet::party
CANTON_INSTANCE, CANTON_SPLICE_VERSIONwhich network this islocalnet::instance, localnet::splice_version
CANTON_ENDPOINToverrides the gRPC URL (not the JSON one)Config::from_env, localnet::grpc_endpoint
CANTON_TOKENoverrides the token, on both transportsConfig::from_env, JsonClient::from_env, localnet::token

The unqualified URL variables point at the app-provider participant, and the token attached by default is CANTON_APP_PROVIDER_JWT. Other participants are reached by role: Config::from_env_for and JsonClient::from_env_for read CANTON_APP_USER_GRPC_LEDGER_API_URL, CANTON_SV_JWT and so on. The role is normalised the way the exporter does it (upper-cased, - becomes _), so "app-user" and "app_user" name the same variables. A role with no JSON URL exported is an error, not a fall-back to the default participant: talking to the wrong node silently is worse than not connecting. localnet::party returns the party id that goes in act_as, not the Ledger API user name; its alias is a role ("app-provider") or a name given to a party later ("bob").

Things this handles for you

Each of these otherwise fails late, with an error that hides the cause.

  • The gRPC URL has no scheme. host:port is what a gRPC client dials, so that is what the exporter prints. Config keeps it as given and the channel builder adds http://, or https:// when TLS is configured. Passed unchanged to a client that expects a scheme, it surfaces as a transport error at the first RPC.
  • The hosts are nginx virtual-host names, such as grpc-ledger-api.app-provider.demo.localhost. The name routes the request, so it has to reach the :authority (gRPC) or Host (HTTP) header. Do not substitute 127.0.0.1: that reaches the port and is refused by the virtual host. *.localhost resolves to loopback on macOS and on Linux with systemd-resolved; elsewhere, add an /etc/hosts entry.
  • A set-but-empty variable counts as unset. An empty CANTON_ENDPOINT or CANTON_TOKEN left over from an earlier shell does not shadow the real LocalNet values.
  • A missing token is not an error. A LocalNet started without authentication exports no JWT, and from_env then returns a configuration with no credentials.
  • A missing endpoint is. from_env returns Error::InvalidRequest naming the variable it looked for and the canton-devkit localnet env command that sets it.

The exported tokens are development credentials from the LocalNet’s own issuer: long-lived and never revoked. Do not carry them into a deployment.

Other local networks

On a network that does not export these variables, either configure the client explicitly:

use canton::auth::{OidcConfig, TokenProvider};
use canton::ledger::CantonClient;
use canton::Config;

// Unauthenticated: Config::new("http://localhost:3901") is all there is to it.
// cn-quickstart: client credentials against its Keycloak.
let oidc = OidcConfig::new(
    "http://keycloak.localhost:8082/realms/AppProvider/protocol/openid-connect/token",
    "app-provider-backend",
    client_secret,
);
let client = CantonClient::connect_lazy(
    Config::new("http://localhost:3901").with_oidc(TokenProvider::new(oidc)),
)?;

or set the names from_env reads yourself, so the same program runs on every network. from_env takes a ready-made token only; for an OIDC issuer, use with_oidc as above.

export CANTON_ENDPOINT=http://localhost:3901
export CANTON_JSON_LEDGER_API_URL=http://localhost:3975
export CANTON_TOKEN="$(canton builder token --validator app-provider)"   # only where a token is wanted

Running the SDK’s tests

No-node tests

Unit tests, in-process gRPC and WebSocket mock servers, TLS handshakes and wire-shape assertions need nothing installed or configured:

cargo test --workspace --all-features
cargo test --workspace

CI runs both lines, on Linux, macOS and Windows. The second one catches a test that names a feature-gated item without being gated itself, which compiles under --all-features and breaks a plain cargo test. The checks to run before a pull request are listed in Contributing.

Live suites

Five test targets talk to real services. Each test skips when the variables it needs are unset, which is why the commands above stay green on a machine with no node.

SuiteCommandNeeds
Ledger APIcargo test -p canton-ledger --all-features --test livea participant
Interactive submissioncargo test -p canton-ledger --all-features --test interactive_livea participant; ParticipantAdmin to onboard an external party
Admin APIcargo test -p canton-admin --all-features --test livea participant, and its admin port for topology reads
PQScargo test -p canton-pqs --all-features --test livea Scribe store following the participant
Token standardcargo test -p canton-token --all-features --test livea token-standard registry (a Splice Scan)

Add -- --nocapture to see what each test reports, including why it skipped.

A skipped test and a passing one are the same line in cargo’s output, so a run that reached no participant reads exactly like one that exercised everything. Set CANTON_TEST_REQUIRE_LIVE=1 on any run whose result you are going to report: every test that would step aside for a missing variable fails instead. In the PQS and token suites, a variable that is set but points at something unreachable is a failure in any case, never a skip.

Variables

The ledger, interactive and admin suites fall back to the canton-devkit export for the endpoint and the token, and the ledger suite for the party too, so after eval "$(canton-devkit localnet env demo)" most of the table below is already covered. Nothing stands in for CANTON_TEST_LICENSING_PKG or CANTON_TEST_ADMIN_ENDPOINT; set those yourself.

VariableWhat it gatesFalls back to
CANTON_TEST_ENDPOINTevery gRPC test in the ledger, interactive and admin suitesCANTON_ENDPOINT, then CANTON_GRPC_LEDGER_API_URL
CANTON_TEST_JSON_ENDPOINTthe JSON-transport and WebSocket testsCANTON_JSON_LEDGER_API_URL
CANTON_TEST_TOKEN_URL, CANTON_TEST_CLIENT_ID, CANTON_TEST_CLIENT_SECRETOIDC client credentialsthe exported token: CANTON_TOKEN, then CANTON_APP_PROVIDER_JWT
CANTON_TEST_PARTYcommand submission and read-backCANTON_APP_PROVIDER_PARTY
CANTON_TEST_LICENSING_PKGthe tests that submit commands: the package they instantiate, #quickstart-licensingnothing
CANTON_TEST_ADMIN_CLIENT_ID, CANTON_TEST_ADMIN_CLIENT_SECRETparty management and external-party onboarding, which need ParticipantAdminan exported token, after asking the participant whether it carries ParticipantAdmin; on an unauthenticated participant the interactive suite needs nothing
CANTON_TEST_ADMIN_ENDPOINTcanton-admin topology reads (3902 on a LocalNet)nothing
CANTON_TEST_SYNC_IDoptional: also assert vetted packages in the synchronizer storenothing
CANTON_TEST_CONTRACT_KEYS_PKGthe interactive suite’s contract-key test: #contractkeys-h61a17c769e62nothing
CANTON_PQS_URLthe PQS suite: a postgres:// URL or a libpq host=… port=… user=… password=… dbname=… stringnothing
CANTON_TOKEN_REGISTRY_URLthe token suite: http://scan.localhost:4000 on a Splice LocalNet or Canton Builder Toolnothing
CANTON_TEST_REQUIRE_LIVEturns every skip into a failure

Credentials. The suites take whichever the environment offers: the OIDC client-credentials flow where CANTON_TEST_TOKEN_URL and the client variables are set, otherwise a ready-made bearer token: CANTON_TOKEN (on Canton Builder Tool, $(canton builder token --validator app-provider); on a Splice LocalNet’s unsafe-jwt-hmac-256 profile, one you signed) or the one canton-devkit exports. A test that attaches a credential skips when there is neither; the interactive suite alone also runs with none against a participant that does not authenticate. ledger_end_with_oidc_auth tests the OIDC flow itself and needs a real issuer. The party-management tests need ParticipantAdmin, from the admin OIDC client or an exported token the participant confirms carries it.

Against cn-quickstart, with its Keycloak:

export CANTON_TEST_ENDPOINT=http://localhost:3901
export CANTON_TEST_JSON_ENDPOINT=http://localhost:3975
export CANTON_TEST_TOKEN_URL=http://keycloak.localhost:8082/realms/AppProvider/protocol/openid-connect/token
export CANTON_TEST_CLIENT_ID=app-provider-backend CANTON_TEST_CLIENT_SECRET=…
export CANTON_TEST_ADMIN_CLIENT_ID=app-provider-validator CANTON_TEST_ADMIN_CLIENT_SECRET=…
export CANTON_TEST_ADMIN_ENDPOINT=http://localhost:3902
export CANTON_TEST_PARTY='app_provider_quickstart-…::1220…'
export CANTON_TEST_LICENSING_PKG='#quickstart-licensing'
export CANTON_TEST_REQUIRE_LIVE=1
cargo test -p canton-ledger --all-features --test live -- --nocapture

Packages for the live suites

The command-submission tests instantiate templates from the cn-quickstart licensing DAR. cn-quickstart has it installed. For any other LocalNet, the DAR the canton-quickstart-licensing bindings were generated from is committed at testdata/live/quickstart-licensing-0.0.1.dar, with its .sha256 next to it. Upload it with canton builder deploy <dar>, canton-devkit’s localnet dar upload, or the JSON Ledger API, as the nightly workflow does:

(cd testdata/live && sha256sum -c quickstart-licensing-0.0.1.dar.sha256)
curl -fsS -X POST "$CANTON_TEST_JSON_ENDPOINT/v2/packages" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @testdata/live/quickstart-licensing-0.0.1.dar
export CANTON_TEST_LICENSING_PKG='#quickstart-licensing'

$TOKEN is an admin token (the workflow fetches one with the CANTON_TEST_ADMIN_CLIENT_* credentials); leave the header out on an unauthenticated participant. The contract-key test takes testdata/contract-keys/contractkeys.dar the same way, with CANTON_TEST_CONTRACT_KEYS_PKG='#contractkeys-h61a17c769e62'.

A PQS store: tools/pqs

The PQS suite reads from a Scribe store. tools/pqs/compose.yaml runs one, Postgres plus Scribe, following the App Provider participant at host.docker.internal:3901:

docker compose -f tools/pqs/compose.yaml up -d
# where the participant wants a token:
# SCRIBE_SOURCE_LEDGER_AUTH=OAuth CANTON_TOKEN=… docker compose -f tools/pqs/compose.yaml up -d
tools/pqs/wait-ready.sh
export CANTON_PQS_URL='postgres://pqs:pqs@localhost:5433/pqs'
CANTON_TEST_REQUIRE_LIVE=1 cargo test -p canton-pqs --all-features --test live -- --nocapture
docker compose -f tools/pqs/compose.yaml down -v
  • Run wait-ready.sh before querying. Scribe applies its schema and ingests from the ledger’s beginning first; the script waits, up to six minutes, until latest_offset() answers and holds still across two checks.
  • The tests read Amulet, so the party the store follows must hold Canton Coin: tap in its wallet first (on a Splice LocalNet, the App Provider wallet at http://wallet.localhost:3000). An empty store fails the suite.
  • SCRIBE_SOURCE_LEDGER_HOST and SCRIBE_SOURCE_LEDGER_PORT point Scribe at another participant; SCRIBE_IMAGE and SCRIBE_VERSION pick the image.
  • cn-quickstart runs a store of its own. Point CANTON_PQS_URL at it instead (postgres://cnadmin:…@localhost:5432/pqs-app-provider).

Querying a store from your own code is covered in Querying PQS.

The token registry

The registry the token suite and examples talk to is the super-validator’s Scan. A Splice LocalNet and Canton Builder Tool publish it at http://scan.localhost:4000. cn-quickstart serves it only when started with SV_PROFILE=on, on port 5012 of its splice container, without publishing the port to the host; forward it:

docker run -d --name canton-rs-scan-forward \
  --network "$(docker network ls --filter name=quickstart --format '{{.Name}}' | head -1)" \
  -p 5012:5012 alpine/socat TCP-LISTEN:5012,fork,reuseaddr TCP:splice:5012
export CANTON_TOKEN_REGISTRY_URL=http://localhost:5012
cargo test -p canton-token --all-features --test live -- --nocapture

The token-standard examples take more variables, described in The token standard.

Tests that need a DAR, not a node

A few code-generation and Daml-LF tests read a DAR path from the environment and skip without one: CANTON_TEST_DAR (one DAR; with CODEGEN_COMPILE_TEST=1 the generated crate is compiled too), CANTON_TEST_DAR_DIR (every DAR in a directory, with CODEGEN_COMPILE_TEST=1), and CANTON_LF_ORACLE_DAR (the canton-lf oracle test, which compares the native decoder with the official JVM reader and needs scala-cli on PATH). The committed testdata/live/quickstart-licensing-0.0.1.dar serves for any of them.

The nightly LocalNet workflow

.github/workflows/localnet.yml runs all five live suites against a disposable LocalNet every night at 03:00 UTC, on manual dispatch, and on pull requests that change the workflow or testdata/live/. In order, it:

  1. Compiles every live suite first, before the LocalNet takes the memory.
  2. Boots a LocalNet with one validator, a PQS store and a party, using the canton-localnet action pinned to a commit.
  3. Maps the action’s CANTON_LOCALNET_* exports to the CANTON_TEST_*, CANTON_PQS_URL and CANTON_TOKEN_REGISTRY_URL names above. A missing export fails this step, instead of reaching a suite as an empty value.
  4. Checks the licensing and contract-keys DARs against their .sha256 files and uploads both over the JSON API. It then confirms the participant holds the package id canton-quickstart-licensing pins, so the committed DAR and the committed bindings cannot drift apart.
  5. Taps Amulet into the validator’s wallet, for the PQS suite to read.
  6. Runs each suite with CANTON_TEST_REQUIRE_LIVE=1 and asserts its exact passed count and zero ignored tests. The PQS step also requires the line its Amulet comparison prints, since one PQS test would otherwise pass on a store with no Amulet.
  7. Tears the LocalNet down, whatever happened before.

The expected counts live in the workflow file, so a change that adds or removes a live test updates them there. What CI covers and what it cannot is set out in the compatibility matrix; the DevNet runs, update ids included, are in Live runs on record.

Reading the ledger

There are three ways to read a participant’s view of the ledger: the active contract set (ACS) as of an offset, the update stream after an offset, and point reads of a single update by id or offset. A typical reader takes a snapshot at the ledger end, streams updates from that same offset, and keeps the offset of the last update it processed so it can resume.

CantonClient (gRPC) and JsonClient (JSON Ledger API) both offer these reads. The differences are listed at the end of this page. Connecting is covered in Getting started, so the snippets take a connected client. Through the canton facade the crates are canton::ledger and canton::daml. Streams are futures_core::Streams, and the snippets pin them with tokio::pin! and call next() through tokio_stream::StreamExt.

Offsets

An offset is an i64 position on the participant’s ledger. ledger_end() returns the current end (0 for an empty view). Update reads cover (begin, end]: begin is exclusive and the optional end inclusive. To continue exactly where a snapshot left off, use the snapshot’s offset as the begin.

The active contract set

active_contracts(parties, offset) streams the contracts active for parties at offset as canton_ledger::proto::ActiveContract. Use ActiveContractsRequest with active_contracts_with to narrow the snapshot or add to it:

Builder methodEffect
for_templates(ids)Only these templates. Each id is package:Module:Entity, where package can be a #package-name reference that keeps working across upgrades.
for_interfaces(ids)Only contracts implementing these interfaces. The created events carry the interface views.
with_created_event_blobs()Include each contract’s created_event_blob, for disclosure in later commands.
non_verbose()Leave out record labels. Typed decoding handles both forms.
for_any_party()Apply the filter for every party the token can read for. Needs wildcard read rights.

A snapshot item is a proto message. Decode its created event with the generated type’s Template::from_created_event (Typed bindings from a DAR); here, the pre-built canton-splice-amulet bindings:

use canton_daml::Template as _;
use canton_ledger::{ActiveContractsRequest, CantonClient, Result};
use canton_splice_amulet::splice_amulet::Splice_Amulet::Amulet;
use tokio_stream::StreamExt as _;

/// The party's amulets, and the offset they were read at.
async fn amulets(client: &CantonClient, party: String) -> Result<(i64, Vec<Amulet>)> {
    let offset = client.ledger_end().await?;
    let request = ActiveContractsRequest::new(vec![party], offset)
        .for_templates(["#splice-amulet:Splice.Amulet:Amulet"])?;
    let stream = client.active_contracts_with(request).await?;
    tokio::pin!(stream);

    let mut amulets = Vec::new();
    while let Some(active) = stream.next().await {
        if let Some(event) = active?.created_event {
            amulets.push(Amulet::from_created_event(&event)?);
        }
    }
    Ok((offset, amulets))
}

from_created_event checks the event’s module, entity and package name before it decodes the payload. It does not compare the package id, so a contract created by an upgraded package version still decodes.

Incomplete reassignments

active_contracts returns only active contracts. With several synchronizers, a snapshot can also hold a contract that is unassigned from one and not yet assigned to the other, and a reader that skips those entries loses it until the reassignment completes. If your application spans synchronizers, use acs_entries or acs_entries_with, which return every entry as an AcsEntry: Active, IncompleteUnassigned or IncompleteAssigned.

Large snapshots

A streamed snapshot that fails partway through has to start over. active_contracts_resumable(parties, offset, max_page_size) and its _with and acs_entries_resumable* forms read the snapshot page by page and retry a failed page from the last continuation token. To manage pages yourself, active_contracts_page and acs_page (and _with forms) return one page and the token for the next, or None after the last page.

Update streams

updates(parties, begin) is a live stream of the transactions and reassignments visible to parties after begin. It never ends on its own, drops the participant’s OffsetCheckpoint messages and requests no topology events. UpdatesRequest with the _with methods sets more. It has the five builder methods of the ACS table above, plus:

Builder methodEffect
until(end)Stop after offset end. The stream ends once it gets there, which is the form to use for catching up.
with_shape(shape)Choose a TransactionShape (see below).
descending()Newest first. Requires until. The resumable stream refuses it.
without_reassignments()Drop reassignments, which are included by default.
with_topology_events()Include participant-authorization topology events for the requested parties.

The shape decides which events a transaction carries:

  • TransactionShape::LedgerEffects (the default) gives create events and exercise events, both consuming and non-consuming, as they were executed. A requesting party must be a witness of an event.
  • TransactionShape::AcsDelta gives created and archived events, the net change to the active contract set. A requesting party must be a stakeholder of an event. Use it to keep a copy of the ACS up to date.

To page through a bounded range instead of streaming it, use updates_page or updates_page_with. The request passed to them must have until.

Typed updates

The reads above return canton_ledger::proto::get_updates_response::Update on gRPC and serde_json::Value on JSON, so code written against one transport cannot read the other, and code matching on the proto types sees every re-vendored proto change with no SemVer signal. New in 0.3.2, canton_ledger::update defines SDK types that both transports produce:

  • Update is Transaction, Reassignment, TopologyTransaction or OffsetCheckpoint, with offset(), update_id() and as_transaction().
  • Transaction carries update_id, command_id, offset, synchronizer_id, effective_at, record_time, events and more, and iterates one kind of event with created(), archived() and exercised().
  • Event is Created, Archived or Exercised. CreatedEvent::decode decodes the payload as a generated template type, with the same identity checks as from_created_event. ExercisedEvent has decode_argument and decode_result.
  • For data these types do not model, the original wire form is still available through as_grpc() and as_json() on CreatedEvent, Reassignment and TopologyTransaction. A reassignment’s or topology transaction’s events are only available that way.

Three reads return these types:

MethodReturns
CantonClient::updates_typed_with(request)async. A stream of Result<Update>.
CantonClient::updates_resumable_typed_with(request)Not async. A resumable stream of Result<Update>.
JsonClient::updates_typed_with(&request, limit)async. A Vec<Update> from a bounded read.

All three drop checkpoints. To convert anything else, use Update::from_grpc (a gRPC stream element), Update::from_grpc_point (a gRPC point read, or the update of an updates_page element) or Update::from_json (a JSON element or WebSocket frame).

This example follows a party’s amulets from a saved offset:

use canton_ledger::update::{Event, Update};
use canton_ledger::{CantonClient, Result, TransactionShape, UpdatesRequest};
use canton_splice_amulet::splice_amulet::Splice_Amulet::Amulet;
use tokio_stream::StreamExt as _;

async fn follow(client: &CantonClient, party: String, after: i64) -> Result<()> {
    let request = UpdatesRequest::new(vec![party], after)
        .with_shape(TransactionShape::AcsDelta)
        .for_templates(["#splice-amulet:Splice.Amulet:Amulet"])?;
    let stream = client.updates_resumable_typed_with(request);
    tokio::pin!(stream);

    while let Some(update) = stream.next().await {
        let update = update?;
        if let Update::Transaction(tx) = &update {
            for event in &tx.events {
                match event {
                    Event::Created(created) => {
                        let amulet: Amulet = created.decode()?;
                        println!("{} created for {}", created.contract_id, amulet.owner);
                    }
                    Event::Archived(archived) => println!("{} archived", archived.contract_id),
                    _ => {}
                }
            }
        }
        // Persist update.offset() here once the update is processed.
    }
    Ok(())
}

Both transports take the same UpdatesRequest and return the same types. In this excerpt from crates/canton-ledger/tests/live.rs, both reads return the same single transaction:

    let request = canton_ledger::UpdatesRequest::new(vec![party.clone()], committed.offset - 1)
        .until(committed.offset);

    let over_grpc: Vec<Update> = client
        .updates_typed_with(request.clone())
        .await
        .expect("open the typed stream")
        .collect::<Result<Vec<_>, _>>()
        .await
        .expect("typed gRPC updates");
    let over_json = json
        .updates_typed_with(&request, None)
        .await
        .expect("typed JSON updates");

    assert_eq!(over_grpc.len(), 1, "{over_grpc:?}");
    assert_eq!(over_json.len(), 1, "{over_json:?}");
    let (Some(g), Some(j)) = (over_grpc[0].as_transaction(), over_json[0].as_transaction()) else {
        panic!("both are the transaction");
    };
    assert_eq!(g.update_id, committed.update_id);
    assert_eq!(g.update_id, j.update_id);
    assert_eq!(g.offset, j.offset);
    assert_eq!(g.synchronizer_id, j.synchronizer_id);
    assert_eq!(g.effective_at, j.effective_at);
    assert_eq!(g.record_time, j.record_time);

ADR-0010 §4 records the plan for 1.0: the typed reads take the plain names (updates, updates_with, updates_resumable, the point reads), and the proto- and JSON-returning reads move to *_raw.

Resumable streams

A plain stream yields the error when its connection drops and does not reconnect. updates_resumable and its _with and _typed_with forms reconnect instead:

  • When an error is retriable (Error::is_retriable), the stream reconnects from the last offset it observed. That includes the participant’s OffsetCheckpoint messages, which it reads but does not yield. On a stream with no activity for your parties, checkpoints are the only thing that moves the resume point forward.
  • The wait between attempts grows linearly. The attempt budget and the step are the client’s RetryConfig max_attempts and initial_backoff (set with Config::with_retry), or 5 reconnects and 250 ms without one. The count resets when an item arrives.
  • When the attempts run out, the stream yields the error that caused the last reconnect, with the participant’s status and details, and ends.
  • A non-retriable error is yielded at once and ends the stream. With until, the stream ends normally when the participant closes it at the end offset.
  • A request that fails validation, or one set to descending(), comes back as an Error::InvalidRequest item. These methods are not async, so the stream is the only place to report it.

To survive a process restart, persist the offset of the last update you processed and pass it as begin next time. Checkpoints are not yielded, so on a quiet stream that offset can trail the stream’s own resume point, and if the participant prunes past it the restart is refused (next section).

Pruned offsets

A participant can prune old history, and it refuses any read that starts inside the pruned range. latest_pruned_offsets(), available on both clients, reports how far pruning has gone as a PrunedOffsets. Updates and completions at or below participant_pruned_up_to_inclusive are gone. all_divulged_contracts_pruned_up_to_inclusive covers divulged contracts and may lag behind.

When a read is refused for this reason, Error::pruned_earliest_offset() returns the earliest offset the participant can still serve, on either transport, and None for any other error. A resumable stream does not retry this refusal; it yields it:

// Before resuming from a saved offset:
let pruned = client.latest_pruned_offsets().await?;
let history_lost = saved < pruned.participant_pruned_up_to_inclusive;

// On an error from a stream or a page:
if let Some(earliest) = err.pruned_earliest_offset() {
    // Readable data begins at `earliest`; the updates before it are gone.
}

The SDK never moves a resume point forward on its own, because both ways out change what your application has seen: resume from the earliest offset and accept that the pruned updates are lost, or rebuild from a fresh ACS snapshot at the ledger end and stream from there.

Point reads

Point reads fetch one update without scanning a stream for it: a stored offset, or a transaction whose id came from a submission’s completion. All of them exist on both clients:

  • update_by_offset(offset, parties) and update_by_id(update_id, parties) use the default format of UpdatesRequest::new.
  • update_by_offset_with(offset, &request) and update_by_id_with(update_id, &request) take their format from an UpdatesRequest: parties, filters, shape, blobs and so on. They ignore the request’s offsets.

If no update with that offset or id is visible to the parties, the result is Ok(None), not an error. A request that names no party is refused before it is sent. The gRPC client returns Option<canton_ledger::proto::get_update_response::Update> and the JSON client returns Option<serde_json::Value>. Convert either one to an Update:

use canton_ledger::update::Update;

if let Some(raw) = client.update_by_id(id.clone(), vec![party.clone()]).await? {
    let update = Update::from_grpc_point(raw)?; // gRPC
    println!("{id} is at offset {}", update.offset());
}
if let Some(raw) = json.update_by_id(id, vec![party]).await? {
    let update = Update::from_json(&raw)?; // JSON
}

For a contract’s create and archive events by contract id, use events_by_contract_id(contract_id, parties) on either client.

gRPC and JSON: differences that matter

gRPC (CantonClient)JSON (JsonClient)
Update readsStreams. Live unless the request has until.updates, updates_with and updates_typed_with return a Vec from one HTTP response.
Size limitStreams are not subject to the JSON list limit.The node’s http-list-max-elements-limit. Past it the read fails with Error::Http status 413, so pass until and/or a limit.
Live tailupdates*ws_updates, ws_updates_with (feature ws).
Resumable updatesupdates_resumable*, with any UpdatesRequest.ws_updates_resumable(parties, begin). Takes no builder, yields raw JSON, and also reconnects after a clean close.
ACSStreamed active_contracts*, acs_entries* with incomplete reassignments, and page-based resumable reads.active_contracts, active_contracts_with (a Vec, with limit). ws_active_contracts* streams it (feature ws), and ws_active_contracts_resumable resumes from the continuation token.
Explicit pagingupdates_page*, active_contracts_page*, acs_page*None.
CheckpointsDropped by every updates* method.Included in the raw updates and updates_with arrays. Dropped by updates_typed_with and ws_updates*.
Point readsOption<proto::get_update_response::Update>Option<serde_json::Value>

JSON ACS elements look like {"workflowId": …, "contractEntry": {"JsActiveContract": {"createdEvent": …}}}. Decode the active ones with Template::from_json_created_event:

for element in json.active_contracts_with(&request, Some(500)).await? {
    if let Some(active) = element["contractEntry"].get("JsActiveContract") {
        amulets.push(Amulet::from_json_created_event(active)?);
    }
}

The ws feature is off by default. Turn it on as canton-ledger/ws, or as ws on the canton facade. On JSON, resumable streams take their reconnect settings from JsonClient::with_retry, the same way as on gRPC.

See also

Typed bindings from a DAR

Codegen turns a DAR into a Rust crate: templates become structs, choices become typed argument structs linked to their template, and every generated type carries both the JSON and the gRPC codec. Three crates are involved:

  • canton-codegen-cli provides the dpm-codegen-rust binary, which writes a complete bindings crate from a DAR. Most projects need only this.
  • canton-codegen is the library behind the binary, for tooling that generates crates itself.
  • canton-daml is the runtime the generated code imports as rt: the Daml primitive types, the Template/Choice traits and the command builders.

If the DARs you need are Splice’s, skip generation and use the pre-built crates.

Install the generator

cargo install canton-codegen-cli --version 0.3.2 --locked
dpm-codegen-rust --version

Install the same release as the SDK crates your application uses. All canton-* crates release in lockstep (ADR-0005), and a generated crate asks for canton-daml at the generator’s own major and minor version (0.3 for this release). --locked builds the binary with the lockfile published with the release instead of the newest dependency versions.

The binary is named for dpm’s subcommand convention: dpm resolves dpm codegen-rust to a dpm-codegen-rust executable, the way git and cargo resolve theirs. With Cargo’s bin directory on your PATH, both spellings run the same binary and take the same flags.

Generate a bindings crate

dpm-codegen-rust --dar dars/my-app-0.1.0.dar --out my-app-bindings --name my-app-bindings

This writes two files, my-app-bindings/Cargo.toml and my-app-bindings/src/lib.rs, covering the DAR’s whole dependency closure. It prints a summary line and, as warnings, any declaration it could not lower.

FlagMeaning
--dar <PATH>The input DAR. Required.
--out <DIR>The output crate directory, created if absent. Required.
--name <NAME>The generated package name. Without it the name comes from the DAR file name with its version dropped: my-app-0.1.0.dar gives my-app.
--runtime-path <PATH>Depend on canton-daml by path instead of from crates.io.
--runtime-version <VER>The canton-daml version requirement. Defaults to the generator’s major and minor version (0.3).
--forceOverwrite a Cargo.toml or src/lib.rs this tool did not write.

Pass --name when the package name has to match the dependency key you will use. The default name leaves the version out so that a DAR version bump does not rename the crate; the crate’s version is taken from the DAR’s main package instead. --runtime-path and --runtime-version both set the canton-daml dependency, so passing both is an error.

The generated manifest declares edition 2021 and leaves license and repository commented out for you to fill in before publishing. Regeneration rewrites both files, so keep hand edits out of them.

Commit the crate and depend on it by path

[dependencies]
my-app-bindings = { path = "my-app-bindings" }
canton          = "0.3"

Committing the output keeps every change to the generated code reviewable in a diff, and it is how the canton-splice-* crates in this repository are built. The bindings crate depends on canton-daml itself; your code can name the runtime as canton::daml through the facade, or depend on canton-daml directly. At matching versions it is one crate.

Use --runtime-path in a monorepo, or to try bindings against an unreleased checkout of the SDK. When the crate depends on canton-daml from crates.io, the tool prints a note pointing to --runtime-path in case that dependency does not resolve.

Regenerating over the tool’s own output

The generator recognises its own files by a marker in their first lines and refuses to overwrite a Cargo.toml or src/lib.rs without it. Regenerating into a directory it wrote needs no --force, and the same DAR produces byte-identical output. --force exists for overwriting files this tool did not write: a thing to do on purpose, not a flag to keep in a script.

The output is formatted with prettyplease, not rustfmt. If your workspace’s cargo fmt reaches the bindings crate, run cargo fmt -p my-app-bindings after every regeneration, including the one in CI below, or the check will report formatting as drift.

Regenerate in CI and fail on a diff

To keep the committed crate in step with the DAR, regenerate in CI and fail when anything changed:

cargo install canton-codegen-cli --version 0.3.2 --locked
dpm-codegen-rust --dar dars/my-app-0.1.0.dar --out my-app-bindings --name my-app-bindings
git diff --exit-code my-app-bindings

The step fails when the DAR changed and nobody regenerated, or when someone edited generated code by hand. When you move to a new SDK release, bump the --version here together with the SDK dependencies.

Why not a build script

Generating during the build does not work, for two reasons:

  • Cargo resolves path dependencies before it runs build scripts. On a clean checkout my-app-bindings does not exist yet, so the build fails before the script that would create it runs.
  • The prost-build arrangement (write into OUT_DIR, then include! it) does not apply either. The emitted tree spans several Daml packages and refers between them by crate::-qualified paths, which resolve to the root of the including crate, not to the module the code was placed in.

Generate ahead of time, commit, and let CI check it.

Using the bindings

Where the types are

A generated crate is a module tree, crate::<package>::<module>::<Type>. The package segment is the Daml package name with hyphens turned into underscores (quickstart-licensing becomes quickstart_licensing); the version is appended only when two packages in the DAR would otherwise share a module name. The module segment is the Daml module with dots replaced by underscores (Licensing.AppInstall becomes Licensing_AppInstall). Each choice’s argument type is named after the choice. The crate-level docs list the packages it contains, and each template’s docs carry its on-ledger id and its choices with their consuming flag.

The reference app, crates/canton-sample, uses bindings generated from the cn-quickstart licensing DAR. That bindings crate, canton-quickstart-licensing, lives in the repository and is not published:

use canton_daml as rt;
use canton_ledger::{CantonClient, Config, JsonClient, JsonCommands, Submit};
use canton_quickstart_licensing::quickstart_licensing::Licensing_AppInstall::{
    AppInstallRequest, AppInstallRequest_Reject,
};
// `Metadata` comes from the crate that owns that Daml package, not from a
// copy inside the app's own bindings — so the value built here is the same
// Rust type every other crate in the ecosystem names.
use canton_splice_api_token_metadata_v1::splice_api_token_metadata_v1::Splice_Api_Token_MetadataV1::Metadata;
use rt::Template as _;

The full mapping from Daml-LF types and declarations to Rust is in Daml-LF to Rust type mapping.

Create, read back, exercise

create_command turns a template payload into a Ledger API Command. Template::from_created_event decodes a CreatedEvent back into the payload type; it is a trait method, so bring rt::Template into scope. The builders do no I/O: the client submits the command.

    // codegen → submit → observe transaction.
    let command = rt::create_command(request);
    let tx = client
        .submit_and_wait_for_transaction(Submit::new(party).add_command(command))
        .await?;
    let created = created_contract_ids(&tx.events);
    println!(
        "submitted — update id {}, {} event(s), offset {}; created {:?}",
        tx.update_id,
        tx.events.len(),
        tx.offset,
        created,
    );

    // The typed READ path: decode the committed event back into the payload.
    let created_event = first_created_event(&tx.events).ok_or_else(|| {
        Error::UnexpectedResponse("the submit returned no created event".to_string())
    })?;
    let read_back = AppInstallRequest::from_created_event(created_event)?;
    assert_eq!(&read_back, request, "typed read-back matches the submit");
    println!(
        "typed read-back — AppInstallRequest for user {}",
        read_back.user
    );

exercise_command takes a typed ContractId<T> and a choice argument. The compiler checks that the choice belongs to the template: the argument type must implement Choice<T>, and the generator implements it only for the templates and interfaces that declare that choice.

    // A typed EXERCISE: reject the install request (consuming), and observe
    // the archive in the committed transaction.
    let contract_id: rt::ContractId<AppInstallRequest> =
        rt::ContractId::new(created_event.contract_id.clone());
    let reject = rt::exercise_command(
        &contract_id,
        &AppInstallRequest_Reject {
            meta: Metadata {
                values: rt::TextMap::new(),
            },
        },
    );
    let tx = client
        .submit_and_wait_for_transaction(Submit::new(party).add_command(reject))
        .await?;

exercise_command also takes a ContractId of an interface, so a choice can be exercised through an interface without knowing the concrete template.

On the JSON Ledger API, the payload’s serde form is the create argument and the template id is the same identifier written out as a string:

    let id = <AppInstallRequest as rt::Contract>::template_id();
    let template_id = format!("{}:{}:{}", id.package_id, id.module_name, id.entity_name);
    // codegen → submit (the payload's LF-JSON is the create argument).
    let arguments = serde_json::to_value(request)?;
    let commands = JsonCommands::new(vec![party.to_string()]).add_create(template_id, arguments);
    let response = client.submit_and_wait_for_transaction(&commands).await?;

Reading events

There are three ways to decode an event into a generated type, and all three run the same identity check:

The check compares the event’s module, entity and package name with the template’s. The package id is deliberately not compared, because under Smart Contract Upgrade the ledger may report any vetted version of the package. An event from a different package that defines the same Module:Entity is refused, even when its payload has the same shape. An event with no template id, or with a package id and no package name, is refused rather than decoded on trust.

The package-name comparison is new in 0.3.2 and can turn a decode that used to succeed into an error. Events a participant returns carry both fields; a CreatedEvent built by hand in a test may need package_name set. See the changelog.

Exercise by key

A template that declares a contract key also implements WithKey, whose Key type is the key’s generated type. exercise_by_key_command takes that key in place of a contract id. With bindings generated from the keyed fixture in testdata/contract-keys, where Account has a record key:

use canton_daml as rt;
// Generated from contractkeys.dar without --name, so the crate is `contractkeys`.
use contractkeys::contractkeys_h61a17c769e62::ContractKeys::{Account, AccountKey, Credit};

let key = AccountKey {
    custodian: rt::Party::new("bank::1220cd"),
    label: "savings".to_string(),
};
let command = rt::exercise_by_key_command::<Account, Credit>(&key, &Credit { delta: rt::Int64(5) });

The fixture is a Daml-LF 2.3 package; no Splice DAR declares a key. A transaction that uses keys is hashed under scheme V3 for interactive submission, which the SDK recomputes; see Interactive submission.

Template ids and Smart Contract Upgrade

Every template and interface implements Contract, which carries PACKAGE_ID, PACKAGE_NAME, MODULE_NAME and ENTITY_NAME. Contract::template_id returns the id in the #<package-name> form, for example #quickstart-licensing:Licensing.AppInstall:AppInstallRequest, and every command builder uses it. The participant then resolves the package version it has vetted, so an application built against one version of a package keeps working when the participant upgrades to a compatible one.

When you need the exact build the bindings were generated from, PACKAGE_ID pins it:

let pinned: &str = <AppInstallRequest as rt::Contract>::PACKAGE_ID;

Module paths do not carry the package version, so regenerating from a bumped DAR leaves every path, type, field and #<package-name> id unchanged and moves only the PACKAGE_ID constants. The runbook and a recorded run are in Regenerating bindings on a DAR upgrade.

Pre-built Splice crates

For the Splice DARs, depend on the published crates instead of generating your own:

  • canton-splice-amulet, canton-splice-wallet, canton-splice-wallet-payments for the Splice application DARs.
  • canton-splice-api-token-*-v1 and -v2 for the token-standard packages (CIP-56 and CIP-0112), and canton-splice-api-featured-app-v1.
  • canton-daml-stdlib for the Daml standard library packages every DAR carries.

They reference each other instead of copying shared packages, so a type such as canton_splice_api_token_holding_v1::splice_api_token_holding_v1::Splice_Api_Token_HoldingV1::Holding is one Rust type whichever crate you reach it through. They release with the rest of the SDK, so keep them at the same version. The API reference links each one on docs.rs, and The token standard shows them in use.

A crate you generate with the CLI embeds its own copy of every package in the DAR’s closure. If your DAR depends on a Splice package, its copy of, say, Holding is a different Rust type from the one in canton-splice-api-token-holding-v1. To reference the published crate instead, use the library and name the package as external, as below.

canton_codegen as a library

canton_codegen::generate is what the CLI runs. Call it from a small tool of your own when the CLI’s flags are not enough, for example to reference packages that are already published as crates:

use canton_codegen::{Options, generate};

let options = Options::new("dars/my-app-0.1.0.dar", "my-app-bindings")
    .with_crate_name("my-app-bindings")
    .with_external_package("splice-api-token-holding-v1", "canton_splice_api_token_holding_v1");
let stats = generate(&options)?;
for skipped in &stats.skipped {
    eprintln!("warning: skipped {skipped}");
}
  • Options mirrors the CLI flags: with_crate_name, with_runtime (taking a Runtime, Version or Path) and with_force.
  • with_external_package is keyed by package name (preferred, since it survives a version bump) or package id. The generated manifest lists each such crate with the version "*" and a # pin this comment; pin it to your SDK version.
  • Stats reports the packages, modules and items written and the declarations skipped. Failures are a GenerateError.

For finer control, lower_dar produces the public ir and generate_crate turns it into the source of lib.rs alone. This repository regenerates its own bindings crates that way, so their curated manifests are left alone; see regenerate_splice.rs. lower_packages_selecting does the same from packages already decoded rather than from a DAR, such as packages downloaded from a participant with the fetch_packages example.

Limits

  • The decoder reads Daml-LF 2.1 to 2.3 and refuses other versions with an error naming them. The current range is in the compatibility matrix.
  • A declaration the generator cannot represent correctly is skipped and reported, not emitted with a wrong encoding. The one known case, a nested Optional behind a type parameter, is described in the type mapping.

The token standard

canton-token runs the Canton Network token-standard workflows: CIP-56 (V1) at the crate root and CIP-0112 (V2) under canton_token::v2. The two standards use the same function names, so the module path tells you which standard a call uses: canton_token::transfer is a V1 transfer and canton_token::v2::transfer is a V2 one.

The crate declares no Daml types; those are generated from the Splice DARs and ship in the canton-splice-api-token-* crates. canton-token adds the workflow: it resolves a factory, asks the registry for a choice context, attaches the contracts the registry says to disclose, and builds the exercise. Submitting it is up to you.

Add it with cargo add canton-token, or use it through the facade crate as canton::token. For what has been verified against which networks, see the compatibility matrix.

Why a registry is involved

A token-standard choice is exercised through an interface. Only the registry knows what reference data the choice needs, such as its configuration contracts, the rules for an instrument or the current round. So a factory workflow (transfer, allocate, settle) takes two steps, and the first one is an HTTP call to the registry:

  1. Send the registry the choice you are about to exercise, with empty extraArgs as the standard requires. The registry replies with the factory contract id, the choice context, and the contracts the participant must be shown.
  2. Exercise the choice on that factory with the context filled in, and disclose the contracts the registry named. The submitting party usually cannot see them, which is what explicit disclosure is for.

The registry client

RegistryClient::new takes the registry’s base URL. On a Canton Network the registry for Amulet is the network’s Scan. The constructor parses the URL right away, so a typo shows up as a configuration error instead of a connection failure. The client gives up on a request after 30 seconds and never follows redirects, because the registry’s answer goes into commands you sign. To supply your own reqwest::Client (proxy, TLS roots), use RegistryClient::with_http_client and configure that client with reqwest::redirect::Policy::none().

info() returns a RegistryInfo with admin_id (the party that administers the instruments) and supported_apis. list_instruments(page_size, page_token) pages through the Instruments the registry issues, and instrument(id) returns Ok(None) for one it does not issue. A 404 that is not the registry’s own, from a wrong base URL or a gateway, is an error rather than None.

Choice contexts and disclosed contracts

The workflow functions call the registry for you. The lower-level methods are public too: transfer_factory, allocation_factory, transfer_instruction_context, allocation_context, and the V2 methods named the same with a _v2 suffix, plus settlement_factory_v2 and allocation_instruction_context_v2. A context for an existing contract is requested per choice, named by TransferInstructionChoice, AllocationChoice or AllocationInstructionChoice, with a ChoiceContextRequest (by default it asks the registry to leave out its debug fields). Never reuse a context for another choice: the standard allows it to be choice-specific.

What comes back is a ChoiceContext:

  • decode::<T>() decodes the context data into a generated type. The registry encodes the data with the Daml JSON API rules that canton-daml implements, so no hand-written mapping is involved.
  • disclosed_contracts() returns the contracts as gRPC DisclosedContracts. The base64 createdEventBlob is decoded to bytes, and the <package>:<Module>:<Entity> template id is split into the three API fields. A blob that is not base64, or a malformed template id, is rejected here as UnexpectedResponse and never reaches the participant.
  • disclosed_contracts_json() returns the same contracts in the registry’s wire form (blob still base64), for a caller building a JSON Ledger API submission.

What a workflow returns

Every workflow function returns a TokenCommand: the exercise command together with the contracts it needs disclosed, so one cannot be submitted without the other. into_submit(act_as) builds a canton_ledger::Submit and into_prepare(act_as) a canton_ledger::interactive::Prepare (for a party whose key the participant does not hold, see Interactive submission and external signing), both with the disclosures attached. into_parts() splits them for a multi-command submission, and transfer_kind() reports how the registry will carry out a transfer.

Picking input holdings

The standard lets a request name no input holdings, but Splice’s reference registry refuses an empty list, and naming a locked holding fails too. Both failures come from the Daml interpreter, after the registry round trip has succeeded. So read the holdings first: holdings::holdings(ledger, owner, Some(instrument_id)) reads the Holding interface views from the active contract set, and holdings::spendable(ledger, owner, instrument_id) returns the unlocked subset to name as inputs. Each HoldingSummary has is_locked(), instrument_id(), amount() and typed::<T>(). The V1 and V2 Holding interfaces name the same contracts, so typed gives you the id as whichever ContractId<Holding> your workflow needs. v1_transfer, v2_transfer and the sender’s side of v2_allocate call spendable when CANTON_TOKEN_HOLDINGS is not set.

Running the examples

The five examples are in crates/canton-token/examples. Run them with cargo run -p canton-token --example <name>. They submit real commands, so set CANTON_TOKEN_DRY_RUN=1 on the first run: the example then builds the command and stops before submitting it.

VariableRead byMeaning
CANTON_TEST_ENDPOINTallThe participant’s gRPC Ledger API, e.g. http://localhost:3901
CANTON_TOKEN_REGISTRY_URLallThe registry’s base URL
CANTON_TOKENallA bearer token as issued. If unset, CANTON_TEST_TOKEN_URL, CANTON_TEST_CLIENT_ID and CANTON_TEST_CLIENT_SECRET (plus CANTON_TEST_AUDIENCE if the issuer requires one) fetch one over OIDC. An unauthenticated LocalNet needs neither
CANTON_TOKEN_DRY_RUNallWhen set, build the command and do not submit it
CANTON_TOKEN_SENDERv1_transfer, v2_transfer, v2_allocate, v2_withdraw_allocationThe sending party
CANTON_TOKEN_RECEIVERv1_transfer, v2_transfer, v2_allocateThe receiving party
CANTON_TOKEN_AMOUNT, CANTON_TOKEN_INSTRUMENTv1_transfer, v2_transfer, v2_allocateAmount and instrument id, e.g. Amulet
CANTON_TOKEN_HOLDINGSv1_transfer, v2_transfer, v2_allocateComma-separated holding contract ids. Optional: when unset, the sender’s unlocked holdings are read from the ledger (the receiver’s side of v2_allocate names none)
CANTON_TOKEN_SENDER_ACCOUNT, CANTON_TOKEN_RECEIVER_ACCOUNTv2_transferV2 account ids. Default to the empty id, which fits a registry with one account per party
CANTON_TOKEN_EXECUTORv2_allocate, v2_settleThe party that settles
CANTON_TOKEN_SIDEv2_allocatesender (default) or receiver
CANTON_TOKEN_SETTLEMENT_IDv2_allocateThe settlement to join. Required on the receiver’s side. The sender’s side generates dvp-<micros> when it is unset
CANTON_TOKEN_SETTLEMENTv2_settleOnly settle this settlement id
CANTON_TOKEN_ALLOCATIONv2_withdraw_allocationOnly withdraw this allocation contract id

Note the two settlement variables: v2_allocate reads CANTON_TOKEN_SETTLEMENT_ID, v2_settle reads CANTON_TOKEN_SETTLEMENT.

CIP-56 (V1) transfers

canton_token::transfer(&registry, &expected_admin, transfer) takes a generated V1 Transfer and returns the TransferFactory_Transfer exercise. expected_admin is required because naming the administrator in the choice is what prevents a registry from substituting a different one. The examples read it from registry.info() to stay short, but that only proves the registry gave the same answer twice. In an application, take it from your own configuration.

From v1_transfer.rs (the authenticate and pick_holdings helpers are earlier in the file):

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let registry = RegistryClient::new(&var("CANTON_TOKEN_REGISTRY_URL")?)?;

    // Who administers the instrument. Read from the registry here for brevity,
    // which only checks that it did not change between this call and the
    // factory call. Naming the administrator in the choice protects against a
    // registry substituting one — but only when the value comes from somewhere
    // the caller trusts, such as configuration, rather than from the registry
    // being checked.
    let info = registry.info().await?;
    let admin = rt::Party::parse(&info.admin_id)?;
    println!("registry admin: {}", info.admin_id);

    let instrument_id = var("CANTON_TOKEN_INSTRUMENT")?;
    match registry.instrument(&instrument_id).await? {
        Some(instrument) => println!(
            "instrument:     {} ({}), {} decimals",
            instrument.name, instrument.symbol, instrument.decimals
        ),
        None => println!("instrument:     {instrument_id} — this registry does not issue it"),
    }

    // The transfer is a generated type. Nothing here re-declares it.
    let sender = rt::Party::parse(&var("CANTON_TOKEN_SENDER")?)?;
    let client =
        CantonClient::connect_lazy(authenticate(Config::new(var("CANTON_TEST_ENDPOINT")?)))?;
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)?
        .as_micros();
    let now = i64::try_from(now)?;
    let inputs = pick_holdings(&client, sender.as_str(), &instrument_id).await?;
    let transfer = ti::Transfer {
        sender: sender.clone(),
        receiver: rt::Party::parse(&var("CANTON_TOKEN_RECEIVER")?)?,
        amount: var("CANTON_TOKEN_AMOUNT")?.parse()?,
        instrument_id: h::InstrumentId {
            admin: admin.clone(),
            id: instrument_id,
        },
        requested_at: rt::Timestamp(now),
        // The window in which this may execute. A registry rejects a transfer
        // whose deadline has passed, so it is not decoration.
        execute_before: rt::Timestamp(now + 10 * 60 * 1_000_000),
        // Which holdings to spend. The standard allows a registry to choose
        // them itself, but it does not require one to: Splice's reference
        // registry refuses an empty list outright — the transfer reaches the
        // Daml interpreter and fails with "At least one holding must be
        // provided". So this is not optional in practice, and naming them also
        // pins exactly which are spent.
        input_holding_cids: inputs,
        meta: md::Metadata {
            values: rt::TextMap::new(),
        },
    };

    // Two steps in one call: resolve the factory, take its context.
    let command = canton_token::transfer(&registry, &admin, transfer).await?;

    match command.transfer_kind() {
        Some(TransferKind::Direct) => println!("kind:           direct — completes on submission"),
        Some(TransferKind::SelfTransfer) => {
            println!("kind:           self — sender and receiver match");
        }
        Some(TransferKind::Offer) => {
            println!("kind:           offer — this will NOT settle until the receiver accepts it");
        }
        None => println!("kind:           the registry did not say"),
        // `TransferKind` is `#[non_exhaustive]`: a kind added later reaches
        // this arm instead of failing to compile here, which is the point.
        Some(other) => println!("kind:           {other:?} — a kind this build does not know"),
    }
    println!(
        "disclosing:     {} contract(s) the registry named",
        command.disclosed_contracts().len()
    );

    if std::env::var_os("CANTON_TOKEN_DRY_RUN").is_some() {
        println!("dry run: the command is built and not submitted");
        return Ok(());
    }

    let transaction = client
        .submit_and_wait_for_transaction(command.into_submit(sender.as_str()))
        .await?;
    println!(
        "committed {} at offset {} with {} event(s)",
        transaction.update_id,
        transaction.offset,
        transaction.events.len()
    );
    Ok(())
}

The registry, not the caller, decides the TransferKind: Direct (the receiver pre-approved direct transfers, so it completes on submission), SelfTransfer, or Offer. An offer creates a TransferInstruction and settles only when the receiver accepts it, so do not report it as done. The enum is #[non_exhaustive] with an Unknown variant, so a kind added later does not break decoding. The receiver completes an offer with canton_token::accept or reject; the sender can withdraw it. Each takes the registry and the instruction’s ContractId<TransferInstruction>.

V1 allocations

canton_token::allocation covers the V1 allocation flow. allocate(registry, expected_admin, allocation, requested_at, input_holding_cids) reserves holdings for one leg of a settlement. On the resulting allocation, the executor calls execute_transfer to settle it or cancel to drop it, and the sender calls withdraw. Each of these choices fetches its own context.

CIP-0112 (V2)

V2 builds commands the same way as V1. If you are porting from V1, these are the changes:

  • Accounts, not parties. A V2 transfer moves between two Accounts (owner, provider, id). A party can hold several accounts, and both owner and provider are optional.
  • Every choice names its actors. Each V2 function takes actors: Vec<Party>, the parties exercising the choice. V1 took them from the submitting party.
  • Settlement goes through a factory. A V2 allocation is settled as part of a batch, so both legs of a delivery-versus-payment move in one transaction.
  • Allocation instructions have choices of their own: accept_allocation_instruction and withdraw_allocation_instruction.

Transfers over accounts

v2::transfer(&registry, transfer, actors) resolves the V2 transfer factory. It needs no expected_admin argument because V2’s TransferFactory_Transfer has no such field; the instrument’s admin is part of the transfer itself. From v2_transfer.rs, where the account helper builds an Account with owner set and provider left as None:

    let sender = rt::Party::parse(&var("CANTON_TOKEN_SENDER")?)?;
    let client =
        CantonClient::connect_lazy(authenticate(Config::new(var("CANTON_TEST_ENDPOINT")?)))?;
    let receiver = rt::Party::parse(&var("CANTON_TOKEN_RECEIVER")?)?;
    let sender_account = account(
        sender.clone(),
        std::env::var("CANTON_TOKEN_SENDER_ACCOUNT").unwrap_or_default(),
    );
    let receiver_account = account(
        receiver,
        std::env::var("CANTON_TOKEN_RECEIVER_ACCOUNT").unwrap_or_default(),
    );
    println!(
        "from account:   {:?} / to account: {:?}",
        sender_account.id, receiver_account.id
    );

    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)?
        .as_micros();
    let now = i64::try_from(now)?;
    let inputs = pick_holdings(&client, sender.as_str(), &instrument_id).await?;
    let transfer = ti::Transfer {
        // The V1 difference in one line: accounts, not parties.
        sender: sender_account,
        receiver: receiver_account,
        amount: var("CANTON_TOKEN_AMOUNT")?.parse()?,
        instrument_id: h::InstrumentId {
            admin,
            id: instrument_id,
        },
        requested_at: rt::Timestamp(now),
        // The window in which this may execute. A registry rejects a transfer
        // whose deadline has passed, so it is not decoration.
        execute_before: rt::Timestamp(now + 10 * 60 * 1_000_000),
        // Which holdings to spend. The standard allows a registry to choose
        // them itself, but it does not require one to: Splice's reference
        // registry refuses an empty list outright — the transfer reaches the
        // Daml interpreter and fails with "At least one holding must be
        // provided". So this is not optional in practice, and naming them also
        // pins exactly which are spent.
        input_holding_cids: inputs,
        meta: md::Metadata {
            values: rt::TextMap::new(),
        },
    };

    // Resolve the factory and take its context — and say who is exercising,
    // which V1 left implicit in the submitting party.
    let command = canton_token::v2::transfer(&registry, transfer, vec![sender.clone()]).await?;

An offered V2 transfer is completed with v2::accept, v2::reject or v2::withdraw. They take the same arguments as the V1 versions plus actors.

Reading what moved

The create and archive events in a transaction show holdings appearing and disappearing, but not which transfer they belonged to. A V2 registry records that by exercising EventLog_HoldingsChange. v2::events::holdings_changes(&transaction.events) returns one HoldingsChange per exercise, with offset, node_id, contract_id and the decoded change.

    let transaction = client
        .submit_and_wait_for_transaction(command.into_submit(sender.as_str()))
        .await?;
    println!(
        "committed {} at offset {} with {} event(s)",
        transaction.update_id,
        transaction.offset,
        transaction.events.len()
    );

    // What actually moved. The creates and archives in this transaction show
    // holdings appearing and disappearing without saying which transfer they
    // belonged to; the registry records that by exercising
    // `EventLog_HoldingsChange`, and this is how a V2 client reads it back.
    for change in canton_token::v2::events::holdings_changes(&transaction.events)? {
        println!(
            "  holdings change on {} (node {}): {} spent, {} produced, {} leg(s)",
            change.contract_id,
            change.node_id,
            change.change.input_holding_cids.len(),
            change.change.output_holding_cids.len(),
            change.change.transfer_leg_sides.len(),
        );
    }

The event is matched on the EventLog interface’s module and entity name, not its package id, which changes with every upgrade of the standard. A holdings change that does not decode is an error, not a skip, so “could not read it” never looks like “nothing moved”.

Allocation and settlement

An allocation reserves holdings for a settlement that a third party, the executor, completes later. The sender allocates its holdings and is the only party that can withdraw them before settlement. The receiver consents to be paid with an allocation of its own, because a leg settles only when both of its sides are authorised. The executor then settles the batch.

v2::allocate(&registry, settlement, allocation, requested_at, input_holding_cids, actors) names the SettlementInfo when the allocation is created. That is what lets the settlement factory check that every allocation in a batch was made for this settlement. From v2_allocate.rs, first the leg sides each run authorises:

/// The sides of the one leg this allocation authorises.
///
/// `SenderSide` says these are the authorizer's holdings being reserved for the
/// other account; `ReceiverSide` is the receiver's consent to be paid. Settling
/// needs *both* sides of a leg authorised, and a transfer pre-approval does not
/// stand in for the receiver's. When the sender is also the receiver, one
/// allocation carries both sides.
fn leg_sides(
    receiving: bool,
    sender: &rt::Party,
    receiver: &rt::Party,
    amount: &rt::Numeric,
    instrument_id: &str,
) -> Vec<al::TransferLegSide> {
    let side = |side, otherside: &rt::Party| al::TransferLegSide {
        transfer_leg_id: "leg-1".to_string(),
        side,
        otherside: account(otherside.clone()),
        amount: amount.clone(),
        instrument_id: instrument_id.to_string(),
        meta: no_meta(),
    };
    if receiving {
        return vec![side(al::TransferSide::ReceiverSide, sender)];
    }
    let mut sides = vec![side(al::TransferSide::SenderSide, receiver)];
    if receiver == sender {
        sides.push(side(al::TransferSide::ReceiverSide, sender));
        println!("receiver = sender: authorising both sides of the leg in this allocation");
    }
    sides
}

then the settlement, the allocation specification and the call:

    // What the allocation belongs to. A V2 allocation names its settlement when
    // it is *created*, which is what lets the settlement factory later check
    // that every leg of a batch was allocated for this one and not another.
    let settlement = al::SettlementInfo {
        executors: vec![executor.clone()],
        id: settlement_id(receiving, now)?,
        // A contract standing for the off-ledger agreement, when there is one.
        cid: None,
        meta: no_meta(),
    };

    let amount: rt::Numeric = var("CANTON_TOKEN_AMOUNT")?.parse()?;
    let transfer_leg_sides = leg_sides(receiving, &sender, &receiver, &amount, &instrument_id);

    let allocation = al::AllocationSpecification {
        admin: admin.clone(),
        // Whose allocation this is: the sender's holdings back the sender's
        // side, and the receiver's side is the receiver's consent.
        authorizer: account(authorizer.clone()),
        transfer_leg_sides,
        // After this the executor can no longer settle and the sender's
        // holdings are theirs again. Absent means no deadline, which is a
        // reservation with no way out — so it is set here on purpose.
        settlement_deadline: Some(deadline),
        next_iteration_funding: None,
        // `false`: the sender may still withdraw. Committing gives that up.
        committed: false,
        meta: no_meta(),
    };

    // The receiver reserves nothing, so it names no holdings unless told to.
    let holdings: Vec<rt::ContractId<h::Holding>> = if receiving {
        named_holdings()
    } else {
        pick_holdings(&client, sender.as_str(), &instrument_id).await?
    };

    let command = canton_token::v2::allocate(
        &registry,
        settlement.clone(),
        allocation,
        rt::Timestamp(now),
        holdings,
        vec![authorizer.clone()],
    )
    .await?;

Set settlement_deadline. Without it the reservation never expires. Leave committed as false if the sender should still be able to withdraw.

The executor settles with v2::settle_batch(&registry, settlement, transfer_legs, allocations, actors), which goes through the registry’s settlement factory. v2_settle reads every allocation that names the executor from the active contract set and groups them by settlement id. It then rebuilds each TransferLeg from the allocations’ TransferLegSides (the legs_of helper) and settles each group as one batch. From v2_settle.rs:

    // One batch per settlement: every allocation made for it settles together.
    let mut batches: BTreeMap<String, Vec<(String, al::AllocationView)>> = BTreeMap::new();
    for (cid, view) in found {
        batches
            .entry(view.settlement.id.clone())
            .or_default()
            .push((cid, view));
    }

    for (settlement_id, allocations) in batches {
        let settlement = allocations[0].1.settlement.clone();
        // Both sides of a leg describe the same leg; the batch names it once.
        let mut by_id: BTreeMap<String, al::TransferLeg> = BTreeMap::new();
        for leg in allocations.iter().flat_map(|(_, v)| legs_of(v)) {
            by_id.entry(leg.transfer_leg_id.clone()).or_insert(leg);
        }
        let legs: Vec<al::TransferLeg> = by_id.into_values().collect();
        let finalized: Vec<al::FinalizedAllocation> = allocations
            .iter()
            .map(|(cid, _)| al::FinalizedAllocation {
                allocation_cid: rt::ContractId::new(cid.clone()),
                extra_transfer_leg_sides: Vec::new(),
                next_iteration_funding: None,
            })
            .collect();
        println!(
            "settlement {settlement_id}: {} allocation(s), {} leg(s)",
            allocations.len(),
            legs.len()
        );
        for leg in &legs {
            println!(
                "  leg {}: {} -> {} : {} {}",
                leg.transfer_leg_id,
                leg.sender.owner.as_ref().map_or("?", |p| p.as_str()),
                leg.receiver.owner.as_ref().map_or("?", |p| p.as_str()),
                leg.amount,
                leg.instrument_id
            );
        }

        let command = canton_token::v2::settle_batch(
            &registry,
            settlement,
            legs,
            finalized,
            vec![executor.clone()],
        )
        .await?;
        println!(
            "  disclosing {} contract(s) the registry named",
            command.disclosed_contracts().len()
        );
        if std::env::var_os("CANTON_TOKEN_DRY_RUN").is_some() {
            println!("  dry run: built and not submitted");
            continue;
        }
        let transaction = client
            .submit_and_wait_for_transaction(command.into_submit(executor.as_str()))
            .await?;
        println!(
            "  settled: {} at offset {} with {} event(s)",
            transaction.update_id,
            transaction.offset,
            transaction.events.len()
        );
    }

V2 has no execute-transfer context. RegistryClient::allocation_context_v2 refuses AllocationChoice::ExecuteTransfer locally with InvalidRequest and points you to the settlement factory. The registry would only have answered with a 404.

A three-party run takes one participant per role, each with its own credentials. The sender runs v2_allocate, which prints the settlement id; the receiver runs it again with CANTON_TOKEN_SIDE=receiver and CANTON_TOKEN_SETTLEMENT_ID set to that id; the executor runs v2_settle. That sequence has been run on a LocalNet with three participants on one synchronizer. The commands, output and read-back are in Live runs on record.

Withdrawing an allocation

If the executor never settles, the allocation holds the sender’s holdings until its deadline. The sender releases them with v2::withdraw_allocation(&registry, &allocation_id, actors). The executor’s counterpart is v2::cancel. v2_withdraw_allocation lists the sender’s active allocations, or only the one named in CANTON_TOKEN_ALLOCATION, and withdraws each one in its own submission. From v2_withdraw_allocation.rs:

    for (cid, view) in active {
        let allocation = rt::ContractId::<al::Allocation>::new(cid.clone());
        let command =
            canton_token::v2::withdraw_allocation(&registry, &allocation, vec![sender.clone()])
                .await?;
        println!(
            "withdrawing {} (settlement {}): disclosing {} contract(s) the registry named",
            &cid[..16],
            view.settlement.id,
            command.disclosed_contracts().len()
        );
        if std::env::var_os("CANTON_TOKEN_DRY_RUN").is_some() {
            println!("dry run: built and not submitted");
            continue;
        }
        let transaction = client
            .submit_and_wait_for_transaction(command.into_submit(sender.as_str()))
            .await?;
        println!(
            "withdrawn:   {} at offset {} with {} event(s)",
            transaction.update_id,
            transaction.offset,
            transaction.events.len()
        );
    }

Which crate holds which types

canton_token::types re-exports the generated modules the workflow functions take as arguments. With it you can name every argument without adding each generated crate as a dependency:

Path in canton_token::typesGenerated crate
metadata (ExtraArgs, ChoiceContext, Metadata)canton-splice-api-token-metadata-v1, shared by V1 and V2
v1::holdingcanton-splice-api-token-holding-v1
v1::transfer_instructioncanton-splice-api-token-transfer-instruction-v1
v1::allocationcanton-splice-api-token-allocation-v1
v1::allocation_instructioncanton-splice-api-token-allocation-instruction-v1
v2::holding (Account, Holding)canton-splice-api-token-holding-v2
v2::transfer_instructioncanton-splice-api-token-transfer-instruction-v2
v2::allocation (SettlementInfo, TransferLeg, FinalizedAllocation)canton-splice-api-token-allocation-v2
v2::allocation_instructioncanton-splice-api-token-allocation-instruction-v2
v2::transfer_events (EventLog)canton-splice-api-token-transfer-events-v2

The V1 Holding interface is also available at the crate root as canton_token::Holding. The allocation-request, burn/mint and featured-app packages are not re-exported; depend on their crates directly. For the full crate-by-crate table, see the compatibility matrix.

Testing

CI drives every workflow function against an in-process registry stub that records each request (tests/inprocess.rs). The live suite (tests/live.rs) runs against a real registry:

CANTON_TOKEN_REGISTRY_URL=http://localhost:5012 \
  cargo test -p canton-token --test live -- --nocapture

Without CANTON_TOKEN_REGISTRY_URL the live tests skip. Set CANTON_TEST_REQUIRE_LIVE=1 to make a skip fail the run. To get a participant and a registry running locally, see Local development and testing.

Interactive submission and external signing

An ordinary submission asks the participant to interpret a command and to authorize it with the party’s key, so the participant holds that key. Interactive submission splits the two. The participant interprets the command and returns the transaction with a hash of it. The party’s key signs the hash wherever it is kept (an HSM, a cloud KMS, an offline machine), and the participant executes the signed transaction without ever seeing the key.

Three crates take part. Interactive submission is on the gRPC client, CantonClient; the JSON client does not offer it.

CrateIts part
canton-adminOnboards the external party: AdminClient::generate_external_party_topology and allocate_external_party.
canton-ledgerPrepare, sign, execute: Prepare, Prepared, Executable, and the CantonClient methods that send them.
canton-signerThe Signer trait, and Ed25519Key, an in-memory implementation.

Dependencies and the ed25519 feature

cargo add canton brings all three, as canton::ledger, canton::admin and canton::signer, with the in-memory Ed25519 key on by default. That key sits in process memory and is not zeroed on drop: fine for development, tests, and deployments that accept the risk. Production keys belong in an HSM or KMS behind your own Signer. To build without the in-memory key type (ring itself stays in the tree, because the TLS stack uses it):

canton = { version = "0.3", default-features = false }

The trait, the signature types and their validation do not depend on the feature. On the individual crates, canton-signer has ed25519 on by default; canton-ledger and canton-admin need only the trait, and their ed25519 feature turns on canton-signer’s.

Onboard an external party

The participant does not hold an external party’s key, so allocating the party takes two calls with a signature in between:

  1. generate_external_party_topology(synchronizer, party_hint, &public_key) returns an ExternalPartyTopology: the party id the participant would assign, the fingerprint it computed for the key, the onboarding topology transactions, and one multi-hash over all of them. Nothing is submitted yet.
  2. allocate_external_party(synchronizer, &topology, &signer, user_id) signs the multi-hash and submits the transactions with the signature. It returns the party id once the allocation has reached the synchronizer, so the party is usable straight away.

Canton computes the fingerprint, and every signature carries it in signed_by, so an Ed25519Key cannot sign for the ledger until into_signer(fingerprint) turns it into an Ed25519Signer.

From the live test (interactive_live.rs), where ledger is a CantonClient, admin an AdminClient, hint the party hint and user_id the ledger user that will submit:

    let synchronizers = ledger
        .connected_synchronizers("")
        .await
        .expect("the participant lists its synchronizers");
    let synchronizer = synchronizers
        .first()
        .expect("the participant is connected to at least one synchronizer")
        .synchronizer_id
        .clone();

    let key = Ed25519Key::generate().expect("a random source");
    let topology = admin
        .generate_external_party_topology(&synchronizer, hint, &key.public_key())
        .await
        .expect("the participant describes the onboarding");

    assert!(
        topology.party_id().starts_with(hint),
        "the party id is built from the hint: {}",
        topology.party_id()
    );
    assert!(
        !topology.transactions().is_empty(),
        "onboarding needs at least one topology transaction"
    );

    let signer = key.into_signer(topology.public_key_fingerprint());
    let party = admin
        .allocate_external_party(&synchronizer, &topology, &signer, &user_id)
        .await
        .expect("the signed onboarding is accepted");
  • Both calls go to the Ledger API port. The live test makes both calls with a token whose user has ParticipantAdmin. The pinned Ledger API contract also authorizes AllocateExternalParty for an identity-provider admin, or for a token whose own user is the user_id passed. A LocalNet without authentication needs nothing beyond the endpoint.
  • user_id gets act_as rights on the new party; an empty string grants none. Canton derives the ledger user from the token’s sub, which in a client-credentials flow is a service-account id, not the OIDC client id. AdminClient::current_user() under the submitting user’s token returns it.
  • The SDK reads the onboarding set before it signs for it. It recomputes the multi-hash and decodes each transaction, and accepts only the key’s own namespace delegation, a mapping of the party to that one key, and hosting with confirmation or observation permission. It also refuses a party id whose namespace is not the key’s fingerprint, and an allocation of any party other than the one signed for.
  • allocate_external_party is not retried: the request has no idempotency key. If the response is lost, look the party up with AdminClient::get_parties before trying again.

Prepare, sign, execute

Each type is one state of the flow and offers only what is possible in it. Prepare says what to interpret and goes to CantonClient::prepare_submission. Prepared is the interpreted transaction and its hash. Executable is signed, and so it can be executed. The round trip from the live test, with party and signer from the onboarding above and command a create that only party signs:

    let prepared = ledger
        .prepare_submission(Prepare::new(&party).add_command(command))
        .await
        .expect("the participant interprets the command");

    assert!(
        !prepared.hash().is_empty(),
        "there must be something to sign"
    );
    assert_eq!(prepared.act_as(), [party.as_str()].map(String::from));

    let executable = prepared
        .sign_with(&signer)
        .await
        .expect("the in-memory key signs");
    assert!(
        executable.unsigned_parties().is_empty(),
        "the only acting party has signed"
    );

    // Boxed: the response carries a whole transaction, so the future is large
    // enough that clippy flags holding it on the stack.
    let transaction = Box::pin(ledger.execute_submission_and_wait_for_transaction(executable))
        .await
        .expect("the participant accepts the signature and commits");

Prepare

Prepare::new(party) acts as one party, Prepare::new_multi(parties) as several. Add commands with add_command / add_commands, built with canton_ledger::create / exercise or from generated bindings. Optional:

MethodEffect
with_command_idThe command component of the change ID. Left unset, a UUID is generated once at prepare time and carried through execution.
with_user_id, with_read_as, with_synchronizer_idAs on an ordinary submission.
with_disclosed_contractsExplicitly disclosed contracts. A token-standard TokenCommand builds the whole Prepare, disclosures included, with into_prepare(party); see The token standard.
with_min_ledger_time_abs, with_min_ledger_time_relLower bound for ledger time. Exclusive; the later call wins.
with_max_record_timeThe record time after which the participant refuses the prepared transaction.
with_hashing_scheme_version, trust_participant_hash, with_verbose_hashingSee Hashing schemes.
with_package_id_selection_preference, with_prefetch_contract_keys, with_taps_max_passes, without_traffic_cost_estimationThe remaining PrepareSubmissionRequest fields.

De-duplication and the submission id belong to execution, on Executable. With Config::with_retry, prepare_submission is retried like a read.

Prepared

hash() is the bytes to sign: the participant’s hash of the transaction, already checked by the SDK, and not a message to hash again. transaction() is the interpreted transaction. Show it to whoever authorizes the signature before signing; the hash proves what is signed, not that it was meant. act_as(), command_id(), change_id(), cost_estimation(), hashing_scheme_version() and hashing_details() report the rest. sign_with(&signer) signs as the only acting party; with several, use sign_as(party, &signer).

Executable

with_deduplication_duration or with_deduplication_offset sets the de-duplication period. with_submission_id fixes the submission id; left unset, each attempt gets a fresh one. with_transaction_shape picks the events the returned transaction carries. A missing or wrong signature is rejected by the participant, not caught locally. Execute it one of three ways:

CantonClient methodReturns
execute_submission() once the participant has accepted the transaction. Acceptance is not commitment: the outcome arrives on the completion stream.
execute_submission_and_waitThe update id and the completion offset, once the transaction has committed.
execute_submission_and_wait_for_transactionThe committed transaction, its events shaped by TransactionShape and filtered for the acting and reading parties.

How long a prepared transaction stays executable

A transaction that depends on time must be executed within the ledger-time tolerance of the ledger time assigned at preparation, usually about a minute. One that does not is bounded by the preparation-time skew, on the order of hours, so hold a Prepared across a long signing ceremony only on purpose. Past with_max_record_time the participant is certain to refuse it, which is what makes preparing the same intent again safe.

Several acting parties

The participant requires a signature from every party in act_as:

let prepared = client
    .prepare_submission(Prepare::new_multi(vec![alice.clone(), bob.clone()]).add_command(command))
    .await?;
let executable = prepared.sign_as(&alice, &alice_signer).await?;
let executable = executable.and_sign_as(&bob, &bob_signer).await?;
assert!(executable.unsigned_parties().is_empty());
let transaction = client.execute_submission_and_wait_for_transaction(executable).await?;

sign_with on a multi-party submission returns Error::InvalidRequest, since it is ambiguous which party the signature is for, and so does signing as a party that is not acting. A party whose topology sets a signing threshold above one calls and_sign_as again with its other keys; the signatures share that party’s entry on the wire. Executable::with_signature(party, &signature) attaches a Signature produced elsewhere, such as an offline ceremony. It is on Executable, so at least one party has to sign through a Signer first; when the only signature comes from such a process, wrap the process in a Signer.

Hashing schemes and the hash check

The signature covers the hash, so the hash decides what is authorized. A participant that returned your transaction next to the hash of a different one would get a valid signature over the other one. The SDK therefore does not take the hash from the response: it recomputes it from the returned transaction and refuses a response whose hash differs (V2 since 0.3.1, V3 since 0.3.2; see the changelog).

Prepare asks for hashing scheme V2 explicitly instead of leaving the choice to the participant. The scheme is echoed in the response and sent back with the signature, so the participant verifies under the scheme it hashed with.

SchemeWhat the SDK doesUse it for
V2The default. Recomputes the hash and refuses a mismatch.Transactions without contract keys.
V3Recomputes the hash and refuses a mismatch.Transactions that use contract keys, which V2 cannot hash.
Any other (V4 is development-protocol-only)Refuses the response unless you set Prepare::trust_participant_hash().Only with a preparing participant you trust.

Ask for V3 with with_hashing_scheme_version, passing the generated proto enum canton_ledger::proto::interactive::HashingSchemeVersion (protocol-stable, not SemVer-stable: ADR-0002):

    let prepared = ledger
        .prepare_submission(
            Prepare::new(&party)
                .add_command(command)
                .with_hashing_scheme_version(HashingSchemeVersion::V3),
        )
        .await
        .expect("the participant prepares under V3 and its hash matches the SDK's");
    assert_eq!(prepared.hashing_scheme_version(), HashingSchemeVersion::V3);

prepare_submission returns Error::UnexpectedResponse, and nothing is signed, when the response has no transaction or no hash; is hashed under an unknown scheme or one other than requested; has a hash that is not 32 bytes (every scheme is SHA-256); under V2 or V3, carries a hash that is not the returned transaction’s, or a transaction that cannot be hashed under that scheme; or uses any other scheme without the opt-in.

trust_participant_hash() signs the participant’s hash as returned and logs a warning. An unchecked hash authorizes whatever that participant hashed. The opt-in does not turn off the check under V2 and V3.

The hashing is public in canton_ledger::hashing: hash_prepared_transaction(prepared, version), hash_prepared_transaction_v2 and hash_prepared_transaction_v3 return the 32-byte hash or a HashingError. Use them when the service that holds the key is not the process that prepared: hand it the PreparedTransaction and it can check the hash itself before signing. To debug a mismatch, prepare with with_verbose_hashing(true) and read Prepared::hashing_details(). The participant may refuse, and the format is not stable: read it, do not parse it.

Implementing Signer for an HSM or KMS

Signer has three methods: public_key(), fingerprint() and an async sign(hash). It is object-safe, so you can hold an Arc<dyn Signer> and pass &*signer where a &dyn Signer is expected. The rules:

  • sign receives a hash the participant produced: prepared_transaction_hash for a submission, the multi-hash for onboarding. Sign those bytes as they are; do not hash them again.
  • fingerprint is the one Canton assigned, from ExternalPartyTopology::public_key_fingerprint(). For onboarding, describe the key with PublicKey::new(KeyFormat, data, KeySpec).
  • Declare the algorithm and format the key actually used (SigningAlgorithm::{Ed25519, EcDsaSha256, EcDsaSha384, MlDsa65}, SignatureFormat::{Concat, Der}). Canton parses the bytes by the format, so Concat for ECDSA, or Der for EdDSA, fails verification.
  • Signature::new refuses empty bytes and an Ed25519 Concat signature that is not 64 bytes, so a truncated reply from a signing service fails there with a clear message rather than as a verification failure.

A sketch for an HSM that holds an Ed25519 key, with HsmClient standing in for your own client. Debug is required; like Ed25519Signer, it shows only the fingerprint, never key material or credentials:

use std::{fmt, future::Future, pin::Pin};

use canton_ledger::{Error, Result};
use canton_signer::{PublicKey, Signature, SignatureFormat, Signer, SigningAlgorithm};

struct HsmSigner {
    hsm: HsmClient,
    key_label: String,
    public_key: PublicKey,
    fingerprint: String,
}

impl fmt::Debug for HsmSigner {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("HsmSigner").field("fingerprint", &self.fingerprint).finish_non_exhaustive()
    }
}

impl Signer for HsmSigner {
    fn public_key(&self) -> PublicKey {
        self.public_key.clone()
    }

    fn fingerprint(&self) -> &str {
        &self.fingerprint
    }

    fn sign(&self, hash: &[u8]) -> Pin<Box<dyn Future<Output = Result<Signature>> + Send + '_>> {
        let hash = hash.to_vec();
        Box::pin(async move {
            let bytes = self
                .hsm
                .sign_ed25519(&self.key_label, &hash)
                .await
                .map_err(|e| Error::Connection(format!("HSM signing failed: {e}")))?;
            Signature::new(
                SignatureFormat::Concat,
                bytes,
                self.fingerprint.clone(),
                SigningAlgorithm::Ed25519,
            )
        })
    }
}

Recovering an ambiguous execute

The change ID (user_id, act_as, command_id) is fixed at preparation, so executing the same Executable twice is de-duplicated rather than committed twice, and the de-duplication covers ordinary submissions of the same command too. Executing consumes the Executable, so take the change ID first, and the offset to read completions from:

let begin = client.ledger_end().await?;
let executable = prepared.sign_with(&signer).await?;
let change_id = executable.change_id();
client.execute_submission(executable).await?;
let completion = client
    .await_completion(&change_id, begin, std::time::Duration::from_secs(60))
    .await?;

await_completion matches on the whole ChangeId, and returns Error::CommandRejected if the ledger rejected the transaction and Error::Timeout if the completion does not arrive in time. With retry enabled, a retried execute_submission answered with ALREADY_EXISTS is reported as success, because the earlier attempt is the one that landed; execute_submission_and_wait reports it as the rejection it is, so recover the outcome with await_completion, as ordinary submissions do in the recover_a_submission example.

Running it against a participant

The live suite interactive_live.rs runs this page against a real participant: it onboards a party, runs the round trip under V2 and V3, creates a keyed contract and exercises it by key under V3, and checks that a signature from the wrong key is rejected.

# unauthenticated LocalNet
CANTON_TEST_ENDPOINT=http://localhost:3901 \
  cargo test -p canton-ledger --test interactive_live -- --nocapture

Against a participant that authenticates, also set CANTON_TEST_TOKEN_URL, CANTON_TEST_ADMIN_CLIENT_ID / CANTON_TEST_ADMIN_CLIENT_SECRET (a client with ParticipantAdmin) and CANTON_TEST_CLIENT_ID / CANTON_TEST_CLIENT_SECRET. The contract-key test needs CANTON_TEST_CONTRACT_KEYS_PKG, after uploading testdata/contract-keys/contractkeys.dar. CANTON_TEST_REQUIRE_LIVE=1 turns a skip into a failure. Local development covers bringing up a participant.

The threat model is in the security review scope, and the Canton versions this is verified on are in the compatibility matrix.

Querying PQS

canton-pqs is a typed read client for the Daml Participant Query Store. You name a template by its generated Rust type, add predicates, and get rows back as the same generated payload type the Ledger API path gives you. The common reads need no SQL, and the values and field paths you pass to a Query are bound as parameters, never spliced into the statement (Sql::raw, below, runs its text as given).

What PQS is

PQS is a Postgres database kept up to date by Scribe, a process that follows one participant’s Ledger API and writes what it reads into the database. The store holds only what Scribe’s ledger connection can see. With authentication on, that means the contracts visible to the token’s user. It exposes the data through SQL functions: active, creates, archives, exercises, lookup_contract, and latest_offset for how far it has ingested.

Payloads are stored as JSONB in the Daml JSON encoding, which is what canton-daml implements, so a contract read here deserializes into the same generated type as one read from a transaction stream. Templates are addressed as PACKAGE_NAME:MODULE_NAME:ENTITY_NAME. The client reads that off the type’s canton_daml::Contract impl. It uses the package name, not the id, so a query keeps working after a Smart Contract Upgrade.

Use PQS to filter in the database, read the active contract set as of an earlier offset, or find an archived contract by id. It is read-only and trails the participant by however far Scribe is behind; for everything else see Reading the ledger. The verified Scribe version is in the compatibility matrix.

Add the dependency

Through the facade, the crate is canton::pqs:

[dependencies]
canton = "0.3"
# or, to reach a store over TLS:
# canton = { version = "0.3", features = ["pqs-tls"] }
FeatureCrateWhat it adds
tlscanton-pqsPqsClient::connect_tls, verified against the platform’s root certificates
pqs-tlscantonForwards canton-pqs/tls, so you don’t need a second dependency for it

TLS is off by default because PQS is usually reached inside a trust boundary. The template types come from a bindings crate: one you generated (Typed bindings from a DAR) or a pre-built one such as canton-splice-amulet, which the examples below use.

Connect

PqsClient::connect takes a libpq connection string (host=… user=… password=… dbname=…) or a postgres:// URL:

use std::time::Duration;
use canton::pqs::PqsClient;

let pqs = PqsClient::connect("postgres://pqs:pqs@localhost:5433/pqs").await?;

// A different deadline for the connect and for every query after it:
let pqs = PqsClient::connect_with_timeout(url, Duration::from_secs(5)).await?;
let pqs = pqs.with_timeout(Duration::from_secs(10));
  • One connection, shared. The client drives a single connection on a spawned Tokio task, so create it inside a Tokio runtime. Clones are cheap and share the connection, which pipelines concurrent queries; pooling is up to you.
  • Timeouts. A connection attempt or query that takes longer than DEFAULT_TIMEOUT (30 seconds) fails with Error::Timeout. with_timeout changes the timeout for the client and every clone of it.
  • Reconnect. If the store closes the connection (failover, idle timeout, restart), the next query opens a fresh connection and runs the statement once more. You don’t need to handle it.
  • No password in logs. The client’s Debug output leaves it out.

TLS

With the tls (or pqs-tls) feature, connect_tls and connect_tls_with_timeout take the same connection string, connect over rustls, and verify the server against the platform’s native root certificates.

TLS is required, not preferred. The client forces sslmode=require, so a store that declines TLS gets an error, not a plaintext session, and an sslmode=disable in the string does not turn TLS off. If no native roots can be loaded or the certificate does not verify, you get Error::InvalidRequest. You can’t supply your own CA bundle or a client certificate.

Read active contracts

active returns the active contracts of one template:

use canton_splice_amulet::splice_amulet::Splice_Amulet::Amulet;

for c in pqs.active::<Amulet>().await? {
    let payload: &Amulet = c.payload();
    println!("{} owner={} created at {}", c.contract_id().as_str(), payload.owner, c.created_at_offset());
}

Each row is a canton_pqs::Contract<T>. This is the row wrapper, not the canton_daml::Contract trait. It carries:

AccessorWhat it is
payload(), into_payload()the decoded T
contract_id()a typed ContractId<T>, usable directly with exercise_command
created_at_offset(), archived_at_offset()ledger offsets; the second is None if never archived
was_active_at(offset)whether the contract was live at that offset
created_effective_at(), archived_effective_at()ledger effective times, as time::OffsetDateTime
signatories(), observers(), witnesses()party ids
package_name(), package_version(), package_id()which package build created it

created_effective_at() is None for contracts Scribe seeded from an ACS snapshot rather than the update stream (SCRIBE_PIPELINE_LEDGER_START=Latest). Because the query matches on the package name, one result can mix contracts from different package versions, and package_version() tells them apart. If any row’s payload does not decode into T, the whole call fails with Error::Payload, and the error names the contract id.

Filter with predicates

Build a Query, add Predicates with filter (they are combined with AND), and run it with run. This example is from the live suite:

    let matching = client
        .run(&Query::<Amulet>::active().filter(Predicate::eq("owner", owner.clone())))
        .await
        .expect("the filtered query runs");
PredicateCompiles toUse it for
Predicate::eq(path, value)payload #> $p = $vexact JSON equality on a field
Predicate::not_eq(path, value)payload #> $p IS DISTINCT FROM $vinequality, including rows where the field is absent
Predicate::compare(path, op, value)payload #>> $p > $v, numeric cast for numbersordered comparisons (Op::Lt, Le, Gt, Ge)
Predicate::contains(json)payload @> $vJSONB containment
Predicate::signatory(party), observer, witness$v = ANY(signatories) and so onparty columns
Predicate::contract_id(id)contract_id = $vone contract

Paths are anything that implements IntoPath: a &str or String for a top-level field, or ["amount", "initialAmount"], a &[&str] or a Vec<String> for a nested one. A string is always one segment, so "a.b" is not split. Segments are the Daml field names as they appear in the JSON (initialAmount), not the Rust field names (initial_amount).

Numbers. LF-JSON carries Int64 and Numeric as strings, and PQS stores them that way:

  • For ordered comparisons, pass a Rust number. The client binds it as text and casts both sides to numeric, so 100 sorts above 9. If you pass a string, the comparison is textual, so do that only for fields that really are text.
  • eq and not_eq turn a JSON number into its string form, which works for Int64. A Numeric is stored with its full scale (an Amulet amount reads "504680.1600000000"), so eq only matches that exact text. For numeric equality, bound the field on both sides with Op::Ge and Op::Le. contains does no conversion either, so write numbers in a containment document as the stored strings.

Absent fields. not_eq uses IS DISTINCT FROM on purpose. A query that spans package versions sees payloads with different field sets, and <> would silently drop every row that lacks the field. An ordered comparison does drop such rows, because “is a missing value greater than 10?” has no answer.

Containment and party columns. contains compiles to @>, the operator a GIN index on payload can serve, and active_signed_by is a shortcut for “the active contracts this party signs”. From the live suite:

    let by_containment = client
        .run(
            &Query::<Amulet>::active()
                .filter(Predicate::contains(serde_json::json!({ "owner": owner }))),
        )
        .await
        .expect("containment runs");
    assert!(!by_containment.is_empty());

    let by_signatory = client
        .run(&canton_pqs::active_signed_by::<Amulet>(&owner))
        .await
        .expect("the signatory query runs");
    assert!(
        !by_signatory.is_empty(),
        "the owner signs, so a signatory filter must find it"
    );

Limit. limit(n) caps the number of rows and adds ORDER BY created_at_offset, contract_id, so two reads at the same offset return the same rows. Without limit, no ordering is applied.

What reaches Postgres

compile returns the statement without running it. The text depends only on the shape of the query. The qname, every value and every field path are bound parameters, so a field name taken from user input is data that Postgres never parses:

use canton::pqs::{Op, Predicate, Query};

// owner: canton_daml::Party
let sql = Query::<Amulet>::active()
    .filter(Predicate::eq("owner", owner.as_str()))
    .filter(Predicate::compare(["amount", "initialAmount"], Op::Gt, 100))
    .limit(50)
    .compile();
// sql.text:
// SELECT * FROM active($1) WHERE payload #> $2 = $3
//   AND (payload #>> $4)::numeric > $5::text::numeric
//   ORDER BY created_at_offset, contract_id LIMIT $6

Read at an offset

latest_offset is how far Scribe has ingested. Query::active_at(offset) reads the active contract set as of that offset, so repeated reads agree with each other while the ledger keeps moving:

    let offset = client.latest_offset().await.expect("an offset");
    let now = client.active::<Amulet>().await.expect("the query runs");
    let pinned = client
        .run(&Query::<Amulet>::active_at(offset))
        .await
        .expect("the pinned query runs");

An offset older than the store holds is an error carrying PQS’s own message, not an empty result: “nothing was active” and “I can’t tell you” differ. A contract returned by active_at(o) may have archived_at_offset() set if it was archived after o. Use was_active_at(o), not archived_at_offset().is_none(), to decide whether it was live. To read back something you just submitted through the Ledger API, wait until latest_offset() reaches the offset it committed at.

Look up by id

lookup reads PQS’s lookup_contract, which finds a contract whether it is active or archived. An unknown id returns Ok(None), not an error:

    let found = client
        .lookup::<Amulet>(sample.contract_id().as_str())
        .await
        .expect("the lookup runs")
        .expect("the contract is there");
    assert_eq!(found.contract_id().as_str(), sample.contract_id().as_str());

    let missing = client
        .lookup::<Amulet>("00deadbeef")
        .await
        .expect("the lookup runs");
    assert!(missing.is_none(), "an unknown id is None, not an error");

Creates, archives and exercises

Query::creates(from, to) and Query::archives(from, to) read the contracts of a template created or archived in an offset range. None on either side uses the store’s own bound: its oldest retained offset (for archives, the pruning boundary if there is one) and its latest offset.

exercises::<T, C>(from, to) reads the exercises of choice C on template T, taking the choice name from the generated Choice impl. Each Exercise<C> has a typed argument(). Its result() is untyped JSON, because PQS doesn’t say what type it is. Decode it with result_as::<R>():

use canton_splice_amulet::splice_amulet::Splice_Amulet::{Amulet, Amulet_Expire, Amulet_ExpireResult};

// last_seen: i64, the offset you have processed up to
for e in pqs.exercises::<Amulet, Amulet_Expire>(Some(last_seen), None).await? {
    let result: Amulet_ExpireResult = e.result_as()?;
    println!("{} by {:?} at {}", e.contract_id(), e.controllers(), e.exercised_at_offset());
}

creates, archives and exercises are covered only by unit tests of the statements they compile to. The other reads also run in the live suite.

Other PQS functions: Sql::raw

For a PQS function or query shape the crate does not model, use Sql::raw with PqsClient::query, which returns tokio_postgres::Rows:

use canton::pqs::{Param, Query, Sql};

let rows = pqs.query(&Sql::raw(
    "SELECT count(*) AS n FROM active($1)",
    vec![Param::Text(Query::<Amulet>::qname())],
)).await?;
let n: i64 = rows[0].get("n");

The parameters are still bound, never interpolated, but the statement text runs as given. Sql::raw is the one place where keeping input out of the statement is your job. To decode rows yourself, for example to skip payloads from another package version instead of failing the whole read, run query(&q.compile()) and call canton_pqs::Contract::<T>::from_row on each row.

Errors, retries and logging

Errors are the SDK-wide canton::Error, classified so that is_retriable() gives the right answer:

SituationErrorRetriable
store unreachableConnectionyes
no answer within the timeoutTimeoutyes
SQLSTATE class 08, 40, 53, 55, 57 (connection loss, serialization failure, too many connections, lock unavailable, shutdown)Connectionyes
connection string does not parseInvalidRequestno
wrong password, unknown database, pg_hba.conf rejectionInvalidRequestno
certificate does not verify, or the store refuses TLSInvalidRequestno
any other rejected statement (syntax, unknown function or column, bad cast)InvalidRequestno
a payload that does not decode into TPayloadno
a column missing from the rowUnexpectedResponseno

Before Scribe has applied its schema, queries fail with errors such as function active(...) does not exist; wait for the store (see below).

The crate emits tracing events only, with no metrics or spans: debug per query (statement text and parameter count, never values), info on a reconnect, and warn when the connection task ends with an error.

What it does not do

  • Write. PQS is a read model. Submit commands through canton-ledger.
  • Stream. There is no subscription. Poll latest_offset(), or use the Ledger API’s update stream.
  • OR, ordering or paging. Filters are combined with AND only. limit fixes the order but takes no cursor or SQL OFFSET. Use Sql::raw for anything beyond that.

Run a store locally

tools/pqs/compose.yaml runs Postgres and Scribe against any LocalNet participant on the host. By default it follows the Ledger API at host.docker.internal:3901, reads from the ledger’s beginning, and publishes Postgres on port 5433. tools/pqs/wait-ready.sh waits until latest_offset() answers and has stopped changing between two checks:

docker compose -f tools/pqs/compose.yaml up -d
# a participant that wants a token:
# SCRIBE_SOURCE_LEDGER_AUTH=OAuth CANTON_TOKEN=… docker compose -f tools/pqs/compose.yaml up -d
tools/pqs/wait-ready.sh
export CANTON_PQS_URL='postgres://pqs:pqs@localhost:5433/pqs'
CANTON_TEST_REQUIRE_LIVE=1 cargo test -p canton-pqs --all-features --test live -- --nocapture
docker compose -f tools/pqs/compose.yaml down -v

The live suite reads Amulet, so first tap some Canton Coin in the wallet of the party the store follows. Otherwise the tests that need a sample contract fail rather than pass with nothing checked. SCRIBE_SOURCE_LEDGER_HOST, SCRIBE_SOURCE_LEDGER_PORT, SCRIBE_PIPELINE_LEDGER_START and SCRIBE_VERSION point the store at another participant or pin another Scribe. See Local development and testing for the participant.

Admin API and telemetry

Two things an application needs in production but not on the transaction path: canton-admin for packages, parties, users and topology, and the telemetry every client call emits, with how to get it into your own tracing and metrics pipeline.

The admin clients

Canton serves these operations on two different gRPC endpoints, so canton-admin has two clients:

ClientEndpointCovers
AdminClientthe Ledger API port (3901 on LocalNet), with the same endpoint and auth as canton-ledgerparties, user self-inspect, package reads, external-party onboarding
TopologyClientthe Canton admin API port (3902 on LocalNet)topology reads: party-to-participant mappings, namespace delegations, vetted packages

Both take the shared Config (endpoint, auth, TLS, retry), re-exported as canton_admin::Config, and connect_lazy connects on the first call. Through the facade they are canton::admin::AdminClient and canton::admin::TopologyClient.

Rights

CallsWhat the token needs
current_user, current_user_rights, acting_parties, participant_idany authenticated user
list_packages, get_package, get_package_statusany authenticated user (the live tests run list_packages and get_package_status with an ordinary token; get_package runs in the fetch_packages example)
allocate_party, list_known_parties, list_known_parties_pageParticipantAdmin
get_partiesParticipantAdmin or identity-provider admin for any party; a plain user only for parties it can act or read as
generate_external_party_topology, allocate_external_partyParticipantAdmin (how the live tests run them)
get_user / list_user_rights for another user’s idParticipantAdmin or identity-provider admin. An empty id means your own user, which any token may read.
every TopologyClient callwhatever the admin API is configured to require. It is unauthenticated on LocalNet. Elsewhere, set a token with Config::with_oidc or Config::with_token.

To fail early rather than with PermissionDenied halfway through a workflow, check the right first, as the live tests do before a party RPC:

use canton_admin::proto::right::Kind;

let rights = admin.current_user_rights().await?;
let is_admin = rights
    .iter()
    .any(|right| matches!(right.kind, Some(Kind::ParticipantAdmin(_))));

acting_parties() returns only explicit CanActAs grants. A user with the wildcard CanActAsAnyParty right gets an empty list, so an empty result does not mean “cannot act”. Read current_user_rights() when the wildcard matters.

Packages

  • list_packages returns the ids of every package the participant knows. It returns ids only.
  • get_package downloads one package as ArchivePayload bytes. A package id is the SHA-256 of its payload, so the SDK hashes the bytes it receives. If they do not hash to the id you asked for, the call fails with Error::UnexpectedResponse. Asking by id therefore pins the content, and you need no separate checksum.
  • get_package_status returns a PackageStatus: Registered for a known package. An unknown id is not an error; its status is something other than Registered.

The bytes from get_package are the payload alone: decode them with canton_lf::decode_payload, not canton_lf::decode_package, which reads the whole Archive in a DAR entry. A JSON-only deployment has JsonClient::list_packages and JsonClient::package_status in canton-ledger instead (json_packages.rs).

Example: download the packages a participant knows

Some packages exist only on a network and have no published DAR. The token standard’s V2 packages are one example. The participant is then the only source, and it is also the most authoritative one. The fetch_packages example downloads every package, or the ones whose name matches a filter:

CANTON_TEST_ENDPOINT=http://localhost:3901 \
CANTON_TEST_TOKEN_URL=… CANTON_TEST_CLIENT_ID=… CANTON_TEST_CLIENT_SECRET=… \
  cargo run -p canton-admin --example fetch_packages -- <out-dir> [name-filter…]

Without the CANTON_TEST_* variables it falls back to canton_core::localnet, which reads the endpoint and token a LocalNet exports. CANTON_ENDPOINT and CANTON_TOKEN override those.

use canton_admin::{AdminClient, Config};
use canton_auth::{OidcConfig, TokenProvider};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut args = std::env::args().skip(1);
    let out = args
        .next()
        .ok_or("usage: fetch_packages <out-dir> [name-filter…]")?;
    let filters: Vec<String> = args.collect();

    let endpoint = std::env::var("CANTON_TEST_ENDPOINT")
        .ok()
        .or_else(|| canton_core::localnet::grpc_endpoint(None))
        .ok_or("set CANTON_TEST_ENDPOINT")?;
    let config = Config::new(endpoint);
    let config = match (
        std::env::var("CANTON_TEST_TOKEN_URL"),
        std::env::var("CANTON_TEST_CLIENT_ID"),
        std::env::var("CANTON_TEST_CLIENT_SECRET"),
    ) {
        (Ok(url), Ok(id), Ok(secret)) => {
            config.with_oidc(TokenProvider::new(OidcConfig::new(url, id, secret)))
        }
        _ => match canton_core::localnet::token(None) {
            Some(token) => config.with_token(token),
            None => config,
        },
    };
    let admin = AdminClient::connect_lazy(config)?;

    std::fs::create_dir_all(&out)?;
    let ids = admin.list_packages().await?;
    println!("the participant knows {} packages", ids.len());

    let mut written = 0usize;
    let mut total = 0usize;
    for id in ids {
        let archive = admin.get_package(&id).await?;
        // The name is inside the payload, so it is read rather than guessed —
        // `list_packages` returns ids and nothing else.
        // A package in an LF version this build does not decode is reported
        // and skipped, not fatal: the participant vets whatever the network
        // agreed on, and one newer package must not hide the rest.
        let package = match canton_lf::decode_payload(&archive) {
            Ok(package) => package,
            Err(canton_lf::DecodeError::UnsupportedMinor { minor, supported }) => {
                eprintln!(
                    "skipping {}: Daml-LF 2.{minor} (this build reads {supported})",
                    &id[..16]
                );
                continue;
            }
            Err(other) => return Err(other.into()),
        };
        let name = canton_lf::package_name(&package).unwrap_or("unnamed");
        let version = canton_lf::package_version(&package).unwrap_or("0.0.0");
        if !filters.is_empty() && !filters.iter().any(|f| name.contains(f.as_str())) {
            continue;
        }

        // `<name>-<version>-<id>.lfpayload`: the id is the content hash, so the
        // file name is its own checksum and two builds of one version never
        // collide. Not `.dalf` — a DAR entry is a whole `Archive` and this is
        // the payload alone, and naming it as though it were the other would
        // invite exactly the decode failure that named this file.
        let path = format!("{out}/{name}-{version}-{id}.lfpayload");
        std::fs::write(&path, &archive)?;
        println!(
            "{name:<45} {version:<8} {:>7} bytes  {}",
            archive.len(),
            &id[..16]
        );
        written += 1;
        total += archive.len();
    }
    println!("wrote {written} package(s), {} KB in total", total / 1024);
    Ok(())
}

Each file is named <name>-<version>-<package id>.lfpayload, so the name carries its own checksum. It is not .dalf, because a DAR entry is a whole Archive and these are payload bytes only. A package in a Daml-LF minor version this build cannot decode is skipped (DecodeError::UnsupportedMinor), not fatal. The repository’s testdata/token-standard-v2 corpus was downloaded this way, and its bindings are generated from that directory; see Typed bindings from a DAR.

Uploading a DAR

canton-admin does not wrap uploads. Packages are only read. On LocalNet, the tools in Local development and testing upload DARs for you. In code, call PackageManagementService through the generated types. canton_admin::proto re-exports them, and they run on a channel built from the same Config. The auth interceptor is canton_core::auth::intercepted, which the canton facade does not re-export, so this needs a direct canton-core dependency:

use canton_admin::proto::UploadDarFileRequest;
use canton_admin::proto::package_management_service_client::PackageManagementServiceClient;

let channel = config.connect_channel()?;
let authed = canton_core::auth::intercepted(&channel, config.auth()).await?;
PackageManagementServiceClient::new(authed)
    .upload_dar_file(UploadDarFileRequest {
        dar_file: std::fs::read("model.dar")?,
        ..Default::default()
    })
    .await?;

Generated types are protocol-stable rather than SemVer-stable: they change when the vendored protos move to a new Canton release (ADR-0002).

Parties

use canton_admin::{AdminClient, Config};

// A token with ParticipantAdmin.
let admin = AdminClient::connect_lazy(config)?;

let alice = admin.allocate_party(Some("alice")).await?; // party = "alice::<fingerprint>"
let everyone = admin.list_known_parties().await?;
let (page, next_token) = admin.list_known_parties_page(100, None).await?;
let found = admin.get_parties(vec![alice.party.clone()]).await?;
  • allocate_party is never retried, even when a RetryConfig is set. Allocation is a topology change that is not idempotent, and a lost response must not allocate the party twice. If it fails ambiguously, look for a party starting with <hint>:: before you allocate again.
  • list_known_parties follows pagination for you. It fails instead of returning a partial list if the participant repeats a page token or keeps going past canton_core::MAX_LIST_PAGES pages. To page by hand, use list_known_parties_page, where a page_size of 0 means the server default.
  • get_parties leaves unknown parties out of its result instead of failing.

Updating party details, the identity-provider fields and the participant-permission options of AllocateParty are deliberately not wrapped; use the generated types.

External parties

An external party’s key stays outside the participant, so allocating one takes two calls with a signature in between. The participant computes the key’s fingerprint, which means the signer cannot be built until the first call returns:

use canton_signer::Ed25519Key;

// Any synchronizer the participant is connected to (canton-ledger's CantonClient).
let synchronizer = ledger
    .connected_synchronizers("")
    .await?
    .into_iter()
    .next()
    .ok_or("not connected to a synchronizer")?
    .synchronizer_id;

let key = Ed25519Key::generate()?;
let topology = admin
    .generate_external_party_topology(&synchronizer, "alice", &key.public_key())
    .await?;
let signer = key.into_signer(topology.public_key_fingerprint());
let party = admin
    .allocate_external_party(&synchronizer, &topology, &signer, &user_id)
    .await?;
assert_eq!(party, topology.party_id());

ExternalPartyTopology is a proposal: nothing is submitted until the key signs its multi_hash(). Before returning it, the SDK recomputes that hash from the onboarding transactions, decodes each one, and refuses (Error::UnexpectedResponse) a set that would authorize more than this party’s own key, such as a delegation to another key, a mapping for another party, or a host with submission permission. allocate_external_party grants user_id act_as on the new party (empty grants nothing), waits until the allocation reaches the synchronizer, and like allocate_party is never retried.

Signing as that party, including with an HSM or KMS behind the Signer trait, is covered in Interactive submission and external signing.

Topology reads

use canton_admin::{Config, Store, TopologyClient};

// LocalNet's admin API is unauthenticated.
let topology = TopologyClient::connect_lazy(Config::new("http://localhost:3902"))?;

let hosting = topology.list_party_to_participant(Store::Authorized, "", "").await?;
for entry in &hosting {
    println!("{} on {} participant(s)", entry.item.party, entry.item.participants.len());
}
let delegations = topology.list_namespace_delegations(Store::Authorized, "", "").await?;
// Vetted packages live in a synchronizer's store, addressed by its physical id.
let vetted = topology.list_vetted_packages(Store::Synchronizer(physical_id), "").await?;
  • An empty filter string matches everything. Store selects the store: Authorized (the node’s own state), Synchronizer(physical_id), or Temporary(name). Vetted packages are stored per synchronizer, so querying them against Store::Authorized returns an empty list.
  • Each result is an Entry with the mapping in item and its validity, serial and store in context. If a row lacks either half, the whole read fails. A list that is silently short would look like a true answer to “which participants host this party”.
  • Reads use the current head state and leave out proposals. TopologyClient is read-only.

Telemetry

What the SDK emits

Every RPC made by the canton-ledger clients (gRPC, JSON and WebSocket) and by both canton-admin clients goes through canton_core::telemetry. The PQS and token-registry clients do not.

SpanFieldsCovers
canton.rpcmethod, transportone call, including every retry attempt
canton.streammethod, transportthe whole life of a stream. Each item is polled inside it.
CounterLabels
canton_client_requests_totalmethod, transport
canton_client_errors_totalmethod, transport, retriable
  • method is the SDK method name, such as allocate_party or list_packages. A convenience call reports under the call it delegates to, so current_user reports as get_user. transport is grpc or json, and it tells the twins apart, since list_packages exists on both. The names are public constants: METRIC_REQUESTS, METRIC_ERRORS, TRANSPORT_GRPC and TRANSPORT_JSON.
  • Successes are not counted separately: success = requests − errors. A retried call counts as one request.
  • A stream counts as one request when it opens. Its errors are counted as they arrive, so a subscription that fails ten minutes in still shows up in canton_client_errors_total. The WebSocket resumable streams (ws_updates_resumable, ws_active_contracts_resumable) are the exception: they count no requests, and a failed reconnect handshake is not counted as an error, so leave them out of a requests − errors calculation.
  • Structured events go with the spans: rpc completed and stream ended at debug, rpc failed and stream failed at warn, with the error and retriable. All of them have the target canton_core::telemetry, so a filter such as canton_core=debug shows every call. Each event carries a trace_id field. It is empty unless an OpenTelemetry context is active (see below).

The SDK emits, the application exports

The SDK installs nothing process-wide: no tracing subscriber, no metrics recorder and no exporter (ADR-0006). Until your application installs them, everything above is a no-op. You choose the log level, format and destination.

Spans and events go through tracing 0.1 and counters through the metrics 0.24 facade, so your subscriber and recorder must be on those major versions to see them. Any metrics recorder works, a Prometheus exporter for example.

The otel feature

otel adds what belongs on the SDK side of an OpenTelemetry setup:

  • otel::otlp_tracer and otel::otlp_tracer_provider build a batching OTLP/gRPC span exporter; take the provider to flush or shut it down.
  • otel::otlp_metrics builds an OTLP metrics pipeline and installs it as the process’s metrics recorder. It fails if a recorder is already installed.
  • W3C trace context (traceparent and tracestate) is added to every outgoing gRPC request’s metadata, to JSON request headers, and to the WebSocket upgrade request. This happens only when a valid OpenTelemetry span context is active. The WebSocket upgrade is the only request a WebSocket stream makes, so it is the only place the context can go.
  • Under such a context the events’ trace_id is filled in, so a log line can be joined to its trace.
You depend onTurn on
cantonfeatures = ["otel"], forwarded to canton-ledger and canton-core
canton-ledgerfeatures = ["otel"], forwarded to canton-core
canton-admin without the otherscanton-core with features = ["otel"]. canton-admin has no otel feature of its own; its gRPC calls go through canton-core’s interceptor.

Wiring an exporter

The application owns the subscriber. The exporter types come from the OpenTelemetry versions canton-core is built on, so match them: opentelemetry 0.27 and tracing-opentelemetry 0.28.

[dependencies]
canton = { version = "0.3", features = ["otel"] }
opentelemetry = "0.27"
tracing = "0.1"
tracing-opentelemetry = "0.28"
tracing-subscriber = "0.3"
use opentelemetry::trace::TracerProvider as _;
use tracing_subscriber::layer::SubscriberExt as _;

const SERVICE: &str = "my-service";
const COLLECTOR: &str = "http://localhost:4317";

// Counters: builds the pipeline and installs it as the `metrics` recorder.
let meters = canton::telemetry::otel::otlp_metrics(SERVICE, COLLECTOR)?;

// Spans: the SDK builds the exporter, the application owns the subscriber.
let spans = canton::telemetry::otel::otlp_tracer_provider(SERVICE, COLLECTOR)?;
let subscriber = tracing_subscriber::registry()
    .with(tracing_opentelemetry::layer().with_tracer(spans.tracer(SERVICE)));
tracing::subscriber::set_global_default(subscriber)?;

// ... run the application ...

// Spans go out in batches and metrics periodically. Flush before exit, or the last batch is lost.
spans.force_flush();
meters.shutdown()?;

Add your own log layer to the same registry, for example tracing_subscriber::fmt::layer(). Keep meters alive for the whole process, because dropping it early loses the last export interval.

Two tests check this path end to end. otlp_export.rs runs its own OTLP collector and checks that the canton.rpc span and both counters arrive with their labels; ws_trace.rs checks that a WebSocket upgrade carries traceparent next to authorization.

API reference

Every published crate is documented on docs.rs, built from the released version. The links below always point at the latest release.

Start here

Crate
cantonThe Canton Network Rust SDK: one-stop entry point re-exporting the canton-* crate family.

Ledger API

Crate
canton-ledgerAsync Canton Ledger API client (gRPC): commands, streaming, de-duplication, recovery.
canton-authJWT/OIDC authentication for the Canton Rust SDK (client-credentials, caching, refresh).
canton-coreCore types for the Canton Rust SDK: error model, telemetry, and the shared connection kernel (config, auth, TLS, retry).
canton-adminCanton admin client: party management, user self-inspect, and topology read (gRPC).
canton-signerPluggable transaction signing for Canton interactive submission (HSM/KMS-compatible).
canton-pqsTyped read client for the Daml Participant Query Store (PQS/Scribe): typed predicates compiled to parameterized JSONB queries.

Daml models and codegen

Crate
canton-damlRuntime types and Value codecs for canton-codegen-generated Daml bindings.
canton-codegenType-safe Rust code generation from Daml packages (DAR to Rust).
canton-codegen-clidpm codegen-rust: generate a typed Rust crate of bindings from a DAR.
canton-lfDaml-LF archive (DAR/DALF) reader and decoder for the Canton Rust SDK.
canton-daml-stdlibPre-built typed Rust bindings for the Daml standard library (daml-stdlib, daml-prim, ghc-stdlib).

Token standard

Crate
canton-tokenCanton Network token-standard workflows (CIP-56 and CIP-0112): registry client, choice contexts, transfers and allocations.
canton-splice-api-featured-app-v1Pre-built typed Rust bindings for the Splice splice-api-featured-app-v1 DAR.
canton-splice-api-token-metadata-v1Pre-built typed Rust bindings for the Splice splice-api-token-metadata-v1 DAR.
canton-splice-api-token-holding-v1Pre-built typed Rust bindings for the Splice splice-api-token-holding-v1 DAR.
canton-splice-api-token-holding-v2Pre-built typed Rust bindings for the Splice splice-api-token-holding-v2 package (CIP-0112).
canton-splice-api-token-allocation-v1Pre-built typed Rust bindings for the Splice splice-api-token-allocation-v1 DAR.
canton-splice-api-token-allocation-v2Pre-built typed Rust bindings for the Splice splice-api-token-allocation-v2 package (CIP-0112).
canton-splice-api-token-burn-mint-v1Pre-built typed Rust bindings for the Splice splice-api-token-burn-mint-v1 DAR.
canton-splice-api-token-transfer-events-v2Pre-built typed Rust bindings for the Splice splice-api-token-transfer-events-v2 package (CIP-0112).
canton-splice-api-token-transfer-instruction-v1Pre-built typed Rust bindings for the Splice splice-api-token-transfer-instruction-v1 DAR.
canton-splice-api-token-transfer-instruction-v2Pre-built typed Rust bindings for the Splice splice-api-token-transfer-instruction-v2 package (CIP-0112).
canton-splice-api-token-allocation-instruction-v1Pre-built typed Rust bindings for the Splice splice-api-token-allocation-instruction-v1 DAR.
canton-splice-api-token-allocation-instruction-v2Pre-built typed Rust bindings for the Splice splice-api-token-allocation-instruction-v2 package (CIP-0112).
canton-splice-api-token-allocation-request-v1Pre-built typed Rust bindings for the Splice splice-api-token-allocation-request-v1 DAR.
canton-splice-api-token-allocation-request-v2Pre-built typed Rust bindings for the Splice splice-api-token-allocation-request-v2 package (CIP-0112).

Splice bindings

Crate
canton-splice-amuletPre-built typed Rust bindings for the Splice splice-amulet DAR.
canton-splice-walletPre-built typed Rust bindings for the Splice splice-wallet DAR.
canton-splice-wallet-paymentsPre-built typed Rust bindings for the Splice splice-wallet-payments DAR.

Generated protocol types

Crate
canton-protoGenerated Canton Ledger API v2 gRPC types and client stubs (internal to the SDK).

canton-codegen-cli is a binary (dpm-codegen-rust), so it has no API documentation; its usage is in Typed bindings from a DAR.

Compatibility matrix

What this SDK is tested against, and what it therefore claims. Nothing is listed on the strength of “it should work” — but not everything is checked by CI on every push, and the table under What CI runs, and what it cannot says exactly which rows run only in the nightly LocalNet workflow.

Version 0.3.2, released 2026-10-23 (the review fixes over 0.3.1: contract keys under hashing scheme V3, point reads, transport-neutral update types).

Rust

ToolchainStatusWhere it is checked
1.88 (MSRV)supportedthe msrv CI job builds the whole workspace with it
stablesupportedevery other CI job
beta / nightlyuntested—

The MSRV is workspace.package.rust-version. Raising it is a minor version bump, not a patch.

Platform

TargetStatusWhere it is checked
Linux (ubuntu-latest)supportedthe test job’s matrix
macOS (macos-latest)supportedthe test job’s matrix
Windows (windows-latest)supportedthe test job’s matrix
aarch64-unknown-linux-gnucompilesthe cross-targets job (cargo check --workspace --all-features); no test run
x86_64-unknown-linux-muslcompilesthe cross-targets job; no test run

The whole test suite runs on all three. The live suites need a Canton participant; CI runs them against LocalNet in the LocalNet workflow, nightly and on demand — see below.

Canton and the Ledger API

Ledger APIv2
Canton3.5.7 — the release the .proto files are vendored from
Verified againsta Canton 3.5.7 participant (LocalNet): submission, streaming, recovery, TLS/mTLS, auth, typed end-to-end on both transports, and interactive submission with an externally-signed party, including contract keys under hashing scheme V3. A Canton 3.5.17 and, since 0.3.2, 3.6.3 participant on the Canton Network DevNet: the token-standard workflows, the ledger live suite, interactive submission under V2 and V3, JSON package and party reads, PQS over TLS, gRPC over TLS with a Keycloak token
Run in CIthe LocalNet workflow boots canton-localnet v0.8.4-3, which vendors Splice 0.8.4, and runs the live suites against it nightly and on demand. The Canton participant is the one that Splice release ships; the workflow does not pin it, and the 3.5.7 row above is the developer-run verification

Moving the supported Canton range re-vendors the protos in a new SDK minor. See the stability policy in canton-proto and ADR-0002.

Daml-LF

VersionStatus
LF 2.1supported
LF 2.2supported
LF 2.3supported since 0.3.1 (the schema is Canton 3.5.17’s; a DevNet participant vets Digital Asset’s utility apps and daml-stdlib 3.5.1 at this version)
LF 2.x, other minorsrefused, naming the minor and this build’s range
LF 1.xrefused, naming the major

An LF 1 archive carries minors like 15, so judging it by the minor gate would report “Daml-LF 2.15 is unsupported” and send a reader to upgrade an SDK that will never read it. The major is checked first for that reason.

The decoder is held to the official JVM reader by a conformance oracle — the lf-conformance CI job.

Token standard

VersionTypesWorkflow
CIP-56 (-v1)generated: holding, metadata, transfer instruction, allocation, allocation instruction, allocation request, burn/mint, featured appcanton-token: registry client, choice contexts with disclosure, TransferFactory_Transfer, accept/reject/withdraw, allocate + execute/withdraw/cancel
CIP-0112 (-v2)generated: holding (with Account), transfer instruction, transfer events (EventLog), allocation (with SettlementFactory, Allocation_Settle), allocation instruction, allocation requestcanton_token::v2: the V2 registry paths, TransferFactory_Transfer over accounts, accept/reject/withdraw, allocate, settle_batch, allocation and allocation-instruction choices, and events::holdings_changes for reading what moved

V2 reuses splice-api-token-metadata-v1; there is no metadata-v2.

Which standard each crate targets, crate by crate:

CrateStandardWhat it holds
canton-splice-api-token-metadata-v1V1 and V2 (shared)ChoiceContext, ExtraArgs, Metadata, AnyValue
canton-splice-api-token-holding-v1CIP-56 V1Holding, InstrumentId, Lock
canton-splice-api-token-transfer-instruction-v1CIP-56 V1TransferFactory, TransferInstruction
canton-splice-api-token-allocation-v1CIP-56 V1Allocation, SettlementInfo, TransferLeg
canton-splice-api-token-allocation-instruction-v1CIP-56 V1AllocationFactory, AllocationInstruction
canton-splice-api-token-allocation-request-v1CIP-56 V1AllocationRequest
canton-splice-api-token-burn-mint-v1CIP-56 V1BurnMintFactory
canton-splice-api-featured-app-v1V1 (featured-app API)FeaturedAppRight, activity markers
canton-splice-api-token-holding-v2CIP-0112 V2Holding, Account, InstrumentId, Lock
canton-splice-api-token-transfer-instruction-v2CIP-0112 V2TransferFactory, TransferInstruction over accounts
canton-splice-api-token-transfer-events-v2CIP-0112 V2EventLog, the transfer events events::holdings_changes reads
canton-splice-api-token-allocation-v2CIP-0112 V2Allocation, SettlementFactory, SettlementInfo, TransferLegSide
canton-splice-api-token-allocation-instruction-v2CIP-0112 V2AllocationFactory, AllocationInstruction
canton-splice-api-token-allocation-request-v2CIP-0112 V2AllocationRequest
canton-splice-amulet, canton-splice-wallet, canton-splice-wallet-paymentsV1 and V2 — Amulet implements boththe Splice application packages, referencing the crates above rather than copying them
canton-tokenV1 (canton_token::*) and V2 (canton_token::v2)the workflows

Verified against live registries — the Splice scan of a cn-quickstart LocalNet (Splice 0.6.11), the Scan of a Canton Builder Tool LocalNet (the three-participant settlement), and the public Scan of the Canton Network DevNet (Splice 0.8.1). The instrument on both is Amulet (Canton Coin), which declares both standards (splice-api-token-transfer-instruction-v1 and -v2). That is a V2 implementation, exercised as one. The registry’s OpenAPI documents at 0.8.1 differ from the vendored 0.6.11 copies by one additive field, which the client already reads.

On the “V2 reference token”. Splice documented a separate Token Standard V2 DevNet — a temporary, single-SV network run by Digital Asset for organizations validating the V2 standard, with a public scan and a weekly Monday reset. It appears to have been wound down: the document’s version line was bumped weekly through 23 June 2026 and has not moved since, and the network’s hostnames are absent from the certificate its address now presents (a valid certificate covering the ordinary DevNet names and no token-std-v2-dev name at all — a retired ingress rather than an IP allowlist, which would answer 403 over a good handshake). V2 has meanwhile landed in the ordinary stack, which is why a plain LocalNet’s Amulet declares every V2 API. So the runs below are against a V2 implementation on a normal network, which is the target that exists.

WhatLocalNet (Canton 3.5.7)DevNet (Canton 3.5.17)
V1 transfer, end to endcommitted at offset 67684, 6 events, kind offercommitted at offset 3316298, kind direct — settled on submission
V2 Account-based transfercommitted at offset 67690, 6 eventscommitted at offset 3316304, direct
V2 event parsing (events::holdings_changes) on a committed transactionone holdings change: 1 spent, 2 produced, 2 legstwo holdings changes: 1 spent / 1 produced, 0 spent / 1 produced
V2 allocation, three distinct parties (sender / receiver / executor)allocated at offset 67693, 6 events, 3 contracts createdallocated at offset 3316319, executor the validator’s party
V2 allocation withdrawn by the sender—withdrawn at offset 3316364; the reserved coin released
V2 allocation settled by its executor (settle_batch)settled by a third party on a three-participant LocalNet (Canton Builder Tool, 2026-10-13): sender, receiver and executor each on its own participantsettled at offset 3317221 through Amulet’s settlement factory; a one-sided leg was refused first, as the standard requires

The runs are kept verbatim, with their environment and commands, in docs/verification/token-standard-live-runs.md; the DevNet update ids were read back from the participant by id after the run.

The allocation is the half that needs more than two parties: the sender reserves holdings for a settlement a third party — the executor — completes later, which is what delivery-versus-payment is built from. On the LocalNet the three roles are three parties on three participants, each submitting with its own participant’s token, and the executor settled both sides of the leg as one batch (the run of 2026-10-13); on DevNet, where one token was held, the settlement was run with the sender as executor and both sides of the leg in one allocation, after the settlement factory had refused a one-sided leg. Both are on record.

Each run resolved the factory against the registry and submitted with the contracts it named for disclosure, so the choice-context and createdEventBlob → DisclosedContract path are proven against a real registry rather than a stub.

The stub has its place too, and CI runs it: every workflow function in canton-token — V1 and V2 transfer, the instruction choices, allocation, settle_batch, the allocation and allocation-instruction choices — is driven against an in-process registry that answers what the OpenAPI documents say and records what it was asked, and the holdings read is driven against an in-process StateService. What those pin is the request each workflow sends and that the registry’s context lands inside the exercise argument, which is the part a mutation run found nothing else guarding.

The V1 packages come from cn-quickstart at a pinned commit, fetched in CI and checked against their SHA-256. The V2 packages ship as no DAR anyone publishes, so they were taken from a participant and committed under testdata/token-standard-v2, where each file name ends with the package id that hashes its bytes.

PQS

Scribe3.5.4
Verified againsta running store: 969 active contracts read as typed payloads, payload and party-column predicates, containment, lookup by id, pinned-offset reads

What CI runs, and what it cannot

SuiteCINeeds
unit, in-process, TLS, WebSocketyesnothing
submit → observe → query, on gRPC and on JSON, as one flowyes, against an in-process participant (submit_observe_query_* in canton-ledger’s tests)nothing; the same flow runs against a real participant in the live suites (create_contract_and_read_transaction, json_submit_and_read_back)
conformance (canton-conformance)yesnothing
bindings drift, eighteen of the nineteen generated cratesyesthe pinned DARs, fetched and checksummed; the V2 and stdlib crates need nothing. canton-quickstart-licensing is the exception — its DAR is built from source, so it is guarded locally and not in the drift job; the DAR is committed under testdata/live for the LocalNet workflow
Daml-LF conformance oracleyesa JVM
live Ledger API, interactive submissionyes, against LocalNet in the LocalNet workflow (nightly, on demand)a Canton participant
live admin (canton-admin)yes, against LocalNet in the LocalNet workflow (nightly, on demand); the vetted(synchronizer) branch of the topology test does not run, because CANTON_TEST_SYNC_ID is unseta Canton participant with its admin API reachable
live PQSyes, against LocalNet in the LocalNet workflow (nightly, on demand)a Scribe store following the participant — tools/pqs/compose.yaml runs one against any LocalNet on the host
token-standard registryyes, against LocalNet in the LocalNet workflow (nightly, on demand)a Splice Scan — the super-validator runs one: a Splice LocalNet or Canton Builder Tool publishes it at scan.localhost:4000; cn-quickstart with SV_PROFILE=on serves it on :5012 without publishing the port. The public DevNet Scan serves the same paths unauthenticated

The LocalNet workflow sets CANTON_TEST_REQUIRE_LIVE, so a suite that cannot reach its environment fails rather than skips, and it asserts each suite’s exact passed count and zero ignored tests: ledger 39, interactive 5, admin 5, PQS 9, token 5. One PQS test skips without that guard when the store holds no Amulet, so the workflow also requires the line that test prints when it compares. Outside CI, a suite gated on an environment variable skips when the variable is unset and fails when it is set and the connection fails — a connection failure and an unconfigured machine used to look the same in a green run, and they are not the same thing.

Conformance to the Ledger Client Standard

conformance/capabilities.toml lists every capability of the standard this SDK claims, and canton-conformance has one test named for each; conformance/ledger-client-standard-mapping.md is the same map for reading, row by row, with the rows the grant does not claim. A completeness guard asserts the two agree in both directions: no capability without a test, and no test claiming a capability the registry does not list.

One row is worth reading carefully. Contract keys are covered by a real keyed DAR, testdata/contract-keys (Daml-LF 2.3, six keyed templates): the conformance row generates bindings from it, canton-codegen’s compile test builds them and an exercise-by-key command from them, and the lf-conformance job holds the decoder to the JVM reader on it. A transaction that uses keys is signed under hashing scheme V3, which the SDK recomputes like V2.

Daml-LF → Rust type mapping

The mapping canton-codegen applies when generating Rust from a DAR. It is implemented in canton-codegen/src/map.rs (types) and emit.rs (declarations); this document is the human-readable specification of that deliverable. Generated code depends on the canton-daml runtime, referenced below as rt.

Primitive and container types

Daml-LF typeRust typeJSON (LF-JSON)gRPC (Value)
Unitrt::Unit{} (empty object)Unit
Boolbooltrue / falseBool
Int64rt::Int64string (number also accepted on input)Int64
Numeric nrt::Numericstring (number also accepted on input)Numeric (string)
TextStringstringText
Partyrt::PartystringParty
Timestamprt::TimestampRFC 3339 stringTimestamp (micros)
Datert::DateYYYY-MM-DD stringDate (days)
ContractId trt::ContractId<T>stringContractId
List t / [t]Vec<T>arrayList
Optional tOption<T>null / valueOptional
Optional (Optional t) …Option<rt::NestedOpt<T>> …nested list form ([] / [x])Optional
TextMap trt::TextMap<T> (BTreeMap<String, T>)objectTextMap
GenMap k vrt::GenMap<K, V>array of [k, v] pairsGenMap
type variable atype parameter A (upper-camel)——
reference to a named typecrate::<package>::<module>::<Type>, or ::<other_crate>::<package>::<module>::<Type> when the package is generated into a crate of its ownper that typeper that type
directly-recursive referenceBox<…>transparenttransparent

Notes on the subtle ones

  • Unit is the empty object {} in LF-JSON, not JSON null — so it maps to a dedicated rt::Unit, not Rust () (whose serde form is null).

  • Nested Optional. The top-level Optional is null/value; every Optional directly inside another uses the LF-JSON list form so Some None ([]) stays distinct from None (null). Only a directly nested one: Optional [Optional t] is Option<Vec<Option<T>>>, because the list element boundary already separates the two. Codegen wraps each directly nested layer in rt::NestedOpt; the gRPC form is an ordinary proto Optional.

  • Int64 is LF-JSON-encoded as a string (encodeInt64AsString, to survive JavaScript’s 53-bit precision); it maps to rt::Int64 (a newtype over i64) which serialises to a string and accepts a string or a number on input, matching the Ledger API. Its gRPC form is a plain Int64.

  • Numeric travels as a decimal string on both wires to avoid binary-float rounding; on JSON input rt::Numeric also accepts a number (high-precision values should still use the string form).

  • References are always fully qualified. Within one crate that is crate::<package>::<module>::<Type>; where a package is generated into a crate of its own — which is how every canton-splice-* crate in this repository is built, so that a Holding is one Rust type rather than one per crate — the reference is absolute and names that crate instead:

    pub meta: ::canton_splice_api_token_metadata_v1::splice_api_token_metadata_v1::Splice_Api_Token_MetadataV1::Metadata,
    pub expires_at: ::canton_daml_stdlib::daml_stdlib_DA_Time_Types::DA_Time_Types::RelTime,

    Which packages are external is the caller’s choice, passed as an ExternalPackages map; anything not in it is emitted into the crate being generated, so cross-package/cross-module references resolve and names from different modules never collide. The package segment is the package name; the version is appended only to separate two packages that would otherwise share a module name, which is how a DAR bundling two versions under Smart Contract Upgrade keeps them distinct (see docs/scu-regeneration.md).

  • Recursion. Daml permits a type to contain itself directly; Rust needs indirection, so a direct self-reference is wrapped in Box.

Declarations

Daml declarationGenerated Rust
recordstruct with #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] + ToValue/FromValue; fields snake-cased, #[serde(rename)] keeps the Daml label; a phantom type parameter (declared but unused) becomes a PhantomData field
variantadjacently-tagged enum ({"tag": <ctor>, "value": <payload>}) + codecs; a nullary constructor carries rt::Unit so its wire form is {"tag":<c>,"value":{}}
enumfieldless enum (also Copy); JSON form is the bare constructor-name string
templatepayload struct + impl rt::Contract (on-ledger id) + impl rt::Template + a typed impl rt::Choice<Self> per choice; a keyed template also gets impl rt::WithKey
interfacea marker struct + impl rt::Contract + impl rt::Interface (its view type) + a typed impl rt::Choice<Marker> per interface choice
contract keytype Key on the template’s rt::WithKey impl

Identity and commands

A template/interface exposes its on-ledger id through rt::Contract (PACKAGE_ID / PACKAGE_NAME / MODULE_NAME / ENTITY_NAME). Contract::template_id() returns the id in the upgrade-friendly #<package-name> form, so the participant resolves the version vetted under Smart Contract Upgrade rather than pinning a package-id hash. The runtime turns a typed payload/choice into a Ledger API command:

  • rt::create_command(&payload) → CreateCommand;
  • rt::exercise_command(&contract_id, &choice_arg) → ExerciseCommand (works on ContractId<Template> and ContractId<Interface>);
  • rt::exercise_by_key_command::<T, _>(&key, &choice_arg) → ExerciseByKeyCommand.

Not represented

Term-level Daml (expression bodies of choices/values), and LF type shapes that are not serialisable (Forall, structural records, type synonyms as such, function/Update/Any types) are not modelled — codegen only needs the serialisable type skeleton. A type that cannot be lowered is skipped and surfaced as a SkippedType rather than emitted incorrectly.

Known limitation: a nested Optional behind a type parameter

LF-JSON encodes an Optional as null/value unless its immediate argument is itself an Optional, in which case that inner one takes the list form — which is why Optional (Optional t) maps to Option<NestedOpt<T>>. The rule is local: a container or record boundary in between already tells an inner None from an outer one, so Optional [Optional t] is the ordinary Option<Vec<Option<T>>> with null elements.

That rewrite is structural, so it cannot see through a type parameter: for data Wrap a = Wrap with value : Optional a instantiated at Wrap (Optional Text), the nesting is real on the wire but absent from the spelled-out Rust type. Nor is the nesting always visible at the instantiation site: data Outer a = Outer with inner : Wrap a nests its own parameter too, through Wrap, so the property is computed as a fixpoint over the reference graph rather than one declaration at a time.

Rather than emit Option<Option<String>> — which would serialize the inner layer as null where the ledger expects [] — codegen refuses that instantiation and reports it as a skipped declaration. Encoding it correctly requires the instantiation site to know how the target uses its parameter, plus a distinct IR node for “this Optional is nested”; until then the case is rejected rather than mis-encoded. The refusal is deliberately narrow — it fires only where the two Optionals really do meet, never across a List, TextMap, GenMap or a ContractId’s phantom tag. It does not occur anywhere in the Splice corpus. The gRPC codec is unaffected — both layers are a proto Optional there.

Conformance against the official daml-lf-archive reader

The decode itself is machine-generated from the official daml-lf-archive protobuf schema (vendored verbatim in canton-lf/proto/), and the hand-written interpretation above it — LF version dispatch, interned-table resolution, package-reference resolution — is verified against the authoritative JVM implementation. The oracle test (canton-lf/tests/oracle.rs) and its JVM twin (tools/lf-oracle/LfOracle.scala, which reads the same DAR through com.daml:daml-lf-archive-reader — the decoder Canton itself uses) each render the DAR’s full type-signature surface (every package: serialisable data types, templates with keys/implements/choices, interfaces with views) to one canonical JSON document, and the test asserts the two documents are equal. The JVM reader also re-computes and checks each archive’s SHA-256, so agreement covers package-id integrity too.

Run it with a JVM and scala-cli on PATH:

CANTON_LF_ORACLE_DAR=/path/to/x.dar cargo test -p canton-lf --test oracle

CI runs it on the vendored fixture (testdata/splice-api-token-holding-v1-1.0.0.dar, 31 packages) in the lf-conformance job, which installs a JVM and scala-cli and fails if the test skips instead of comparing — a conformance claim is worth what a reviewer can re-run. Locally the same test has been run across the whole Splice surface (splice-amulet, splice-wallet, splice-wallet-payments, the token-standard API DARs, quickstart-licensing): 245 package decodings, zero disagreements. Like the other DAR-dependent suites, the test skips cleanly when the env var or toolchain is absent.

Smart Contract Upgrade (SCU) and regeneration

How the generated bindings stay usable across a package version bump, and the runbook to demonstrate it.

What the codegen does for SCU

  • The module path does not carry the version. A package’s types live under crate::<name>::<module>::<Type> (e.g. crate::quickstart_licensing::Licensing_AppInstall::AppInstallRequest), so a version bump does not rename every path a consumer imports. The version is appended only where it has to disambiguate — a DAR that bundles two versions of one package under SCU generates my_app_1_0_0 and my_app_2_0_0, and both are reachable. If even that repeats (a rebuilt package keeps its metadata but gets a new id) the package-id prefix is appended too. Covered by unit tests in lower.rs.
  • References resolve by package id. Every cross-package reference is resolved through the package id hash (self / imported-by-hash / import-table), so a reference always resolves to the exact version it named — verified by the 18-DAR corpus compiling with all cross-package references intact.
  • Version-independent template id. Contract::template_id() uses the #<package-name> form, not a pinned package-id hash. A create/exercise command therefore targets whatever version the participant has vetted, which is the point of SCU — verified live: canton-sample commits #quickstart-licensing:Licensing.AppInstall:AppInstallRequest and the participant resolves the vetted version.

Together these are the SCU mechanics: a consumer built against v1’s types keeps working when the participant upgrades to v2, because commands are addressed by package name, and regenerating against v2 yields the same module, type and field names — the consumer’s use paths and call sites are untouched.

Demo: regenerate on a minor version bump (~30 min)

Requires the Daml toolchain (the daml CLI used by cn-quickstart) to build a second version of a package. Example with the licensing package:

# 1. Bump the version and rebuild the DAR.
cd .../quickstart/daml/licensing
sed -i '' 's/^version: 0.0.1/version: 0.0.2/' daml.yaml
daml build                      # → .daml/dist/quickstart-licensing-0.0.2.dar

# 2. Regenerate the bindings from the bumped DAR.
cd <sdk-repo>
cargo run -p canton-codegen-cli --bin dpm-codegen-rust -- \
  --dar .../quickstart-licensing-0.0.2.dar --out /tmp/licensing-0.0.2 \
  --name canton-quickstart-licensing

# 3. Diff against the checked-in v0.0.1 bindings.
diff <(cargo run -q -p canton-codegen-cli --bin dpm-codegen-rust -- \
         --dar .../quickstart-licensing-0.0.1.dar --out /tmp/l1 && cat /tmp/l1/src/lib.rs) \
     /tmp/licensing-0.0.2/src/lib.rs

Expected: the only difference is the PACKAGE_ID constant on each template/interface (the new archive hashes differently). Every module path, template/interface/choice/field name, and every #<package-name> template id is unchanged — so a consumer’s code compiles untouched and its commands still work against the upgraded package. That is “a version bump regenerates compatible code.”

If the DAR bundles both versions (the SCU deployment shape), the two package modules appear side by side as quickstart_licensing_0_0_1 and quickstart_licensing_0_0_2 instead.

Result (run 2026-08-09)

Run without the Daml toolchain, which is not installed here. A version bump lives in one place — PackageMetadata.version_interned_str inside the main package — so the bump was applied there and the DAR rebuilt around it: crates/canton-lf/examples/bump_version.rs rewrites the version, re-hashes the payload into a new package id, and repacks the archive with the renamed entries and manifest.

That is deliberately not a recompile. The Daml source is untouched and only the declared version moves, which isolates the version change from every other difference daml build would introduce — and the version change is what the property is about.

version    0.0.1 -> 0.0.2
package id edd5a8d8…a919a -> 340f3c57…df589

Regenerating from both DARs and diffing the output:

v0.0.1v0.0.2differing
differing lines, whole file8
…all of themconst PACKAGE_ID
pub mod49490
pub struct75750
pub enum880
#[serde(rename …)]3703700
ENTITY_NAME / MODULE_NAME / PACKAGE_NAME8 / 8 / 88 / 8 / 80

And the consumer side, which is the claim that matters: one unchanged program — building a payload, a create command, a typed exercise, and both codecs — compiles and runs against both sets of bindings, and emits the same version-independent template id:

against v0.0.1:  #quickstart-licensing:Licensing.AppInstall:AppInstallRequest
                 pinned package id edd5a8d8…a919a
against v0.0.2:  #quickstart-licensing:Licensing.AppInstall:AppInstallRequest
                 pinned package id 340f3c57…df589

Not one character of the consumer changed. That is “an SCU version bump regenerates compatible code”.

What this does not cover: a real daml build of a modified package, where the Daml source itself changes (a field added, a choice added). SCU’s compatibility rules for that are the participant’s to enforce, and demonstrating it needs the Daml toolchain and a deployed upgrade.

Architecture Decision Records

Each significant, hard-to-reverse decision gets a numbered record: the context at the time, the decision, and its consequences. Records are immutable — a reversal is a new ADR that supersedes the old one.

ADRTitleStatus
0001Vendored protos, pinned to a Canton releaseAccepted
0002Generated proto types are protocol-stable, not SemVer-stableAccepted
0003Shared connection kernel lives in canton-coreAccepted
0004Returned streams are detached from the client lifetime (use<>)Accepted
0005All canton-* crates version in lockstepAccepted
0006Telemetry: the SDK emits, the application exportsAccepted
0007A thin canton facade crate is the documented entry pointAccepted
0008A native Rust Daml-LF decoder (not the JVM daml-lf-archive)Accepted
0009Daml-LF version-support policy for codegenAccepted
0010API changes deliberately deferred to 1.0Accepted

ADR-0001: Vendored protos, pinned to a Canton release

Status: Accepted (M1) · Date: 2026-07

Context

The SDK’s gRPC surface is generated from the Canton Ledger API v2 .proto files (plus the Canton admin-api topology-read closure and grpc.health.v1). The protos could be fetched at build time (git submodule, build-script download) or vendored into the repository.

Decision

Vendor the .proto files under crates/canton-proto/proto/, pinned to a specific Canton release (currently 3.5.7, extracted from the official release artifacts). build.rs compiles everything under that directory with a vendored protoc, so neither consumers nor docs.rs need any system tooling or network access.

Consequences

  • Reproducible builds; cargo build works offline and on docs.rs.
  • The supported Canton version is explicit and testable (the README compatibility table); moving it is a deliberate re-vendor commit.
  • We carry ~a dozen third-party proto files in-tree and must refresh them per supported Canton release (M3’s compatibility matrix automates the check).
  • Vendoring makes the tree editable, which a schema copied from elsewhere must not be. proto/PROVENANCE.md records what each subtree tracks and which release it is pinned to, proto/SHA256SUMS records a hash per file, and canton-proto’s test suite fails if any of them changes — so a local edit to a third-party schema cannot pass unnoticed. tools/vendor-protos.sh re-hashes after a deliberate re-vendor and can diff the tree against the copies inside a running Canton container.

ADR-0002: Generated proto types are protocol-stable, not SemVer-stable

Status: Accepted (M1) · Date: 2026-07

Context

Until the M2 code generator produces typed Daml bindings, the client’s dynamic path necessarily exposes generated protobuf types (canton_ledger::proto, pb::Transaction, pb::Completion, …). Thousands of generated types in the public surface cannot realistically carry a hand-maintained SemVer promise, and a prost/tonic major bump or a Canton re-vendor changes them wholesale.

Decision

Split the stability promise in two, documented in canton-proto and on every proto re-export:

  • Hand-written SDK types (Config, Error, builders, clients) carry the normal SemVer guarantee.
  • Generated proto types are protocol-stable: they track the vendored protos pinned to the Canton release (ADR-0001) and are explicitly exempt from SemVer. They may change in any release that re-vendors protos or bumps prost/tonic.

Consequences

  • Honest, checkable contract for design partners; cargo-semver-checks is scoped to the hand-written surface.
  • Consumers needing insulation from wire types use the generated bindings for payloads and canton_ledger::update for the update envelope. The proto-typed update reads stay public until 1.0, when they move behind *_raw names (ADR-0010 §4).

ADR-0003: Shared connection kernel lives in canton-core

Status: Accepted (M1) · Date: 2026-07

Context

canton-ledger and canton-admin both need endpoint/TLS/auth/retry wiring. Originally these were pub(crate) inside canton-ledger; canton-admin would have had to duplicate them or depend on canton-ledger.

Decision

Lift the connection kernel — Config, Auth + the object-safe TokenSource trait, TlsConfig, channel building, and the retry pipeline — into canton-core. Client crates depend on the kernel, never on each other:

canton-proto → canton-core → canton-auth → {canton-ledger, canton-admin}

TokenSource (in core) breaks the would-be cycle with canton-auth: TokenProvider implements the trait, so Config::with_oidc accepts it without core knowing about auth.

Consequences

  • One TLS/auth/retry implementation, identical behavior across clients (the JSON and WS transports build from the same TlsConfig).
  • No client→client coupling; M2/M3 crates (canton-codegen runtime, canton-token, canton-pqs) get the same foundation.
  • canton-core carries tonic’s transport+TLS stack; auth-only consumers pay that compile cost (acceptable; revisit with a connect feature if demanded).

ADR-0004: Returned streams are detached from the client lifetime (use<>)

Status: Accepted (M1) · Date: 2026-07

Context

In edition 2024, return-position impl Trait captures all in-scope lifetimes by default. Every SDK method returning impl Stream from &self therefore borrowed the client for the stream’s whole life — even though the bodies only clone cheap handles. tokio::spawn-ing a stream (the dominant consumer pattern) failed to compile (E0505) without pre-cloning the client.

Decision

Every stream-returning method declares precise capturing — impl Stream + Send + use<> — and bodies clone what they need up front. A permanent compile-test (canton-ledger/tests/stream_capture_probe.rs) drops the client and spawns each stream; any signature regression breaks the build.

Consequences

  • let s = client.updates(...).await?; drop(client); tokio::spawn(s) works.
  • Method bodies must not actually borrow from self past the first await — enforced by the compiler through the use<> bound itself.

ADR-0005: All canton-* crates version in lockstep

Status: Accepted (pre-first-release) · Date: 2026-07

Context

The workspace ships five interdependent crates (canton-proto, canton-core, canton-auth, canton-ledger, canton-admin; later canton-codegen, canton-token, canton-pqs, and the canton-splice-* family). Independent per-crate versions would force a compatibility matrix between our own crates and make “which versions work together?” a real user question.

Decision

One version for the whole family, driven by workspace.package.version:

  • Every canton-* crate is released in lockstep with the same version, even when a crate has no changes.
  • Internal dependencies name the release’s own version in the workspace dependency table (version = "x.y.z"), which Cargo reads as a caret requirement. This constrains the minor, not the patch: with 0.2.0 published, a consumer whose lockfile pairs canton-ledger 0.2.0 with canton-core 0.2.1 resolves and builds. Crossing a minor does not — that is where the guarantee lies, and it is the boundary this project breaks API at before 1.0.
  • Mixed patch versions are therefore possible and are expected to work: the release train publishes them together, and a patch is by definition non-breaking. Pinning =x.y.z between our own crates would forbid the resolution rather than the misbehaviour, and would make a security patch to one crate un-installable without re-releasing all of them.
  • A breaking change in any crate bumps the shared version’s SemVer-major (post-1.0; pre-1.0 the minor plays that role).
  • The canton-splice-* generated crates (M2+) additionally encode the DAR version in their own version metadata but follow the same release train.

Consequences

  • Users hold exactly one version number; the README compatibility table maps it to the supported Canton release.
  • Some releases re-publish unchanged crates — accepted cost for a trivially correct compatibility story (same trade the C# SDK made).

ADR-0006: Telemetry — the SDK emits, the application exports

Status: Accepted (M1) · Date: 2026-07

Context

An SDK that installs a global tracing subscriber, metrics recorder, or OTLP exporter fights the host application for process-global state and drags heavy exporter dependencies into every build.

Decision

The SDK only emits through the ecosystem facades: a canton.rpc tracing span per RPC (method + transport fields) and metrics counters (canton_client_requests_total / canton_client_errors_total, labelled by method, transport, retriability). The application installs whatever subscriber/recorder it wants. The optional otel feature adds the two pieces that genuinely belong SDK-side: an OTLP span-exporter constructor (telemetry::otel::otlp_tracer) and automatic W3C trace-context injection into outgoing gRPC metadata and JSON headers.

Consequences

  • Zero process-global state claimed by the SDK; no exporter deps in default builds.
  • Per-endpoint success/error breakdowns come from the labels; “success” = requests − errors.
  • Log level/format/destination are consumer-owned (standard Rust model), which is how the proposal’s “configurable logging” is satisfied.

ADR-0007: A thin canton facade crate is the documented entry point

Status: Accepted (pre-first-release) · Date: 2026-07

Context

The proposal’s distribution model is per-crate (cargo add canton-ledger), and the SDK works fully that way. But a family of five crates leaves three gaps: there is no single “start here” crate on crates.io/docs.rs; feature flags live in different crates (ws in canton-ledger, otel in two places); and the proposal’s adoption gate counts crates.io downloads, which are tracked per crate with no family aggregation — transitive dependencies inflate the foundation crates’ counters, so no member crate is a clean measure of “installs of the SDK”.

Decision

Ship a sixth crate, canton, that is a thin facade: re-exports only (canton::ledger, canton::auth, canton::admin, plus the shared canton-core types at the root), the ws/otel features forwarded, no logic of its own. Documentation and quickstarts lead with cargo add canton; per-crate installation stays supported and documented as the “pick pieces” path.

The facade must stay thin: no wrapper types, no helpers, no prelude until M2’s typed bindings show what users actually need (a “thick” facade would add a maintenance surface that can drift from the member crates).

Consequences

  • cargo add canton gives the whole SDK as one lockstep-versioned set (ADR-0005), with one place to enable features.
  • The canton download counter becomes a clean, conservative adoption metric: nobody pulls the facade transitively, so its count ≈ deliberate SDK installs. (A lower bound — direct per-crate users bypass it.)
  • One more crate in the release train — re-exports only, so the publish cost is a single extra cargo publish.
  • The most discoverable name in the ecosystem hosts the official entry point instead of being left to squatters.

ADR-0008: A native Rust Daml-LF decoder (not the JVM daml-lf-archive)

Status: Accepted (M2) · Date: 2026-07 · Supersedes the LF-decoder plan in proposal §M2

Context

M2 codegen has to turn a .dar into a typed Rust AST. The proposal committed to decoding LF through the JVM daml-lf-archive via a thin JVM helper, explicitly “rather than re-implementing LF decoding natively in Rust … LF decoding and version dispatch are non-trivial … wrapping it is materially less risky than a hand-written Rust LF parser that would drift from the spec.”

Two things reframed that during implementation:

  1. We are not writing a bespoke parser. The concern the proposal guarded against was a hand-rolled LF parser drifting from the spec. But the Daml-LF protobuf schema is itself part of daml-lf-archive (daml_lf.proto / daml_lf2.proto live in that directory). Vendoring that authoritative schema and decoding it with prost is using daml-lf-archive’s own definitions — not a bespoke parser. Codegen also only needs the type-level structure (data types, templates, choices, interfaces, keys), never Daml expressions (those run on the participant), so the surface to decode is small and bounded.

  2. The JVM breaks the native-Rust premise. Wrapping the JVM decoder puts a Java + jar build-time dependency (and a Rust↔JVM subprocess/serialization bridge) in front of every developer who generates bindings from their own DAR — the opposite of the prost-like, pure-cargo build-script workflow that design partners explicitly asked for.

Precedent (surveyed): the funded, public, JVM-free peer — Go’s noders-team/ go-daml — decodes the LF protobuf natively (vendored .pb.go, proto.Unmarshal, zero subprocess). The C# SDK and DA’s own TypeScript codegen are JVM-backed; K2F’s Rust effort is mixed (native proto parsing + codegen referencing daml-lf-archive). So native is a shipping, precedented approach for non-JVM Canton codegen, though not the majority pattern.

Decision

Decode Daml-LF natively in Rust: vendor daml-lf-archive’s own LF 2.x .proto schema and decode packages with prost (canton-lf), resolving the interning tables and recovering package id/version/PackageMap (the SCU inputs) ourselves. No JVM at build time.

  • SCU stays consumer-of-results, exactly as the proposal describes: we read the resolved package id/hash/version/PackageMap; we never re-implement upgrade-compatibility checks.
  • The decoder sits behind the IR seam (ADR: IR is decoder-agnostic), so the choice is reversible — a JVM daml-lf-archive backend could be added behind the same IR without touching the generator, if ever needed.

Consequences

  • Better DX / best-practice for a Rust SDK: a build.rs reads a DAR and generates types with no JVM, no subprocess — the workflow Rust users want. Consumers of pre-built canton-splice-* crates never touch a decoder either way, so only the (rare) “generate from my own DAR” path is affected, and it is the path that benefits most.
  • We own the decode+lower maintenance: re-vendor the LF proto per supported Daml/LF release and keep the lowering correct (bounded to type-level structure). Go demonstrates this is maintainable.
  • This deviates from the proposal’s stated JVM path — deliberately, as a product improvement (no JVM), using daml-lf-archive’s own schema. To be raised with the committee at M2 submission rather than presented as-is; reversible via the IR seam if they prefer the JVM wrap.

ADR-0009: Daml-LF version-support policy for codegen

Status: Accepted (M2) · Date: 2026-07

Context

The codegen’s LF decoder (ADR-0008) is built against a specific vendored LF protobuf schema, mirroring how the Ledger API protos are pinned to a Canton release (ADR-0001). Three version numbers are currently in play and must not be conflated:

  • Canton release the SDK targets (M1 pins Canton 3.5.7 for the Ledger API protos).
  • Daml-LF schema the codegen decodes (vendored from daml v3.3.0-snapshot.20250507.0, i.e. LF 2.x).
  • DAR SDK-version — the Sdk-Version in a DAR’s manifest (test corpus DAR is 3.3.0).

These differ today (a DAR built with SDK 3.3 decodes fine while the Ledger API is pinned to 3.5.7) because LF 2.x is stable across this range — the wire schema does not change between these minor releases. But “it happens to work” is not a policy, and a reviewer will ask what range is actually supported.

Decision

  • Codegen supports the Daml-LF 2.x wire schema as vendored, independent of the Canton release the client crates target. LF-version compatibility is a property of the LF major/minor, not of the Canton patch version.
  • The vendored LF schema’s provenance (daml ref + LF version) is recorded in canton-lf (as the Ledger API protos are in canton-proto), and re-vendoring to a newer LF is a deliberate, changelogged commit.
  • The supported DAR SDK-version range is stated in the README compatibility table alongside the Canton/Ledger-API/MSRV row.
  • The test corpus must include a golden DAR from the pinned Canton line (3.5.x), not only the 3.3 DAR currently used, so decode is exercised against the same release the client targets. (Open task — the 3.5.x golden DAR still needs to be sourced and added to the env-gated decode test.)

Consequences

  • Clear, defensible answer to “which DARs does codegen accept”: any LF 2.x DAR in the supported range, decoupled from the client’s Canton pin.
  • A single documented place (README table + canton-lf provenance) to update on each re-vendor, mirroring ADR-0001.
  • One follow-up owed: add the 3.5.x golden DAR to the corpus.

ADR-0010: API changes deliberately deferred to 1.0

Status

Accepted (2026-08-24). Nothing here changes before the 1.0 release; all of it changes at 1.0 unless re-argued.

Context

An audit of the public surface against the Rust API Guidelines found two deviations whose fix is a breaking change. Pre-1.0 the family is at 0.x, where breaking is allowed but still costs every early adopter a migration. Neither deviation is dangerous enough to charge that cost now; both are wrong enough that 1.0 — the last cheap moment — should pay it. Recording the decision is the point: an unrecorded “later” is how a wart becomes permanent.

Decisions

1. PartyParseError / NumericParseError become ParsePartyError / ParseNumericError (C-WORD-ORDER). The std convention is verb-object-error (ParseIntError); ours inverts it, and it is the exact example the guideline names. At 1.0 the types are renamed with deprecated type aliases kept for one minor release.

2. Party loses its unvalidated From<&str> / From<String> impls. Today the most idiomatic spelling ("x".into()) routes to the unchecked wire-path constructor while "x".parse() validates — the two spellings a reader expects to be equivalent differ in exactly the property that matters. The impls also block adding a validating TryFrom<&str>: the std blanket impl<T, U: From<T>> TryFrom<T> for U makes TryFrom and From mutually exclusive for the same pair of types, so the checked conversion cannot even be offered while the unchecked one holds the slot. At 1.0 the From impls go, TryFrom<&str> arrives with PartyParseError, and Party::new remains the documented unchecked door for wire values.

3. TlsConfig::client_identity_pem becomes a private field behind a typed accessor. Today it is a pub field of type Option<(Vec<u8>, Vec<u8>)> — the mutual-TLS certificate and private key as an untyped pair, with “which element is the key” an undocumented invariant. The #[non_exhaustive] attribute and the with_client_identity builder already steer callers to the safe path, and Debug redacts the key, so nothing leaks today; but a pub field carrying key material, tuple-ordered, is a shape 1.0 should not freeze. At 1.0 the field goes private and the identity is read through a small typed accessor. Deferred rather than done now for the same reason as the others: it is a breaking change, and 0.x users should migrate once.

4. Update reads return the SDK’s own types; the prost ones become the escape hatch. ADR-0002 promised that raw proto types would leave the default public path, and for update reads they had not: updates_resumable yields pb::get_updates_response::Update and the JSON client returns raw JSON, so code written against one transport did not read the other, and a re-vendored proto reached callers who matched on it without a version signal (nodejumper-org/canton-rust-sdk#11). The types now exist and are additive: canton_ledger::update (Update, Transaction, Event with created, archived and exercised events, Reassignment, TopologyTransaction, OffsetCheckpoint), built from either transport, with payloads decoded by the generated type and the wire form still reachable. updates_typed_with and updates_resumable_typed_with (gRPC) and updates_typed_with (JSON) return them, and Update::from_grpc, from_grpc_point and from_json convert anything else. At 1.0 the typed reads take the plain names (updates, updates_with, updates_resumable, the point reads) and the proto- and JSON-returning ones move to *_raw, so the default path is transport-neutral and the wire types are something a caller opts into. Deferred for the reason the others are: renaming now would break every 0.x reader of updates, and they should migrate once.

Consequences

  • These changes ride the 1.0 release train, listed in its migration notes.
  • Until then the current shapes stay: churning them at 0.x would break users twice for the same lesson.

Independent security review: scope

Status: draft, for agreement with the Canton Foundation’s Tech & Ops security subcommittee before the review begins. The proposal (#407, milestone 3) makes the review a pass-through item: the auditor and this scope are agreed with the subcommittee, this document is published before the review starts, and the report and a remediation summary are published here when it is done. Nothing below is final until the subcommittee has agreed it; the section “Open with the subcommittee” lists what still needs their word.

What is reviewed

The commit under review is the tag named in the milestone-3 submission (v0.3.1, which carries the fixes from the internal pre-audit; v0.3.0 is the milestone release) of nodejumper-org/canton-rust-sdk (Apache-2.0). The proposal names three groups — the client, the codegen, and the token crates — which are these crates:

GroupCratesHand-written Rust, linesWhat it does
Clientcanton-core, canton-auth, canton-ledger, canton-admin, canton-signer, canton-pqs, canton≈ 10,500TLS and mTLS, OAuth client-credentials and token refresh, retry and error classification, the gRPC and JSON Ledger API clients, interactive submission and the Signer trait with the in-memory Ed25519 key, the PQS query compiler and Postgres client, the admin and topology reads
Codegencanton-lf, canton-codegen, canton-codegen-cli, canton-daml≈ 7,300the Daml-LF archive decoder, the lowering and emitter that turn a DAR into a Rust crate, the CLI, and the runtime the generated code links against (JSON and gRPC codecs, commands)
Tokencanton-token≈ 2,100the token-standard registry client, choice contexts and disclosure, the V1 and V2 workflows

Lines are source without comments and blank lines, src/ only. The fourteen canton-splice-api-* crates, canton-daml-stdlib, canton-splice-amulet, canton-splice-wallet and canton-splice-wallet-payments are generated output of canton-codegen from Splice’s DARs, reproduced byte for byte by CI’s bindings-drift job; they are covered by reviewing the generator, not by reading them.

Threat model

The SDK runs inside an application that holds credentials and, with interactive submission, may hold or reach a signing key. What an attacker can control, and what the SDK must guarantee in each case:

The attacker controlsThe SDK must
Responses from a participant (gRPC or JSON): a compromised or impersonated node, a proxynot panic, not loop, not allocate without bound on a hostile payload; classify errors so that a permanent failure is never retried forever; never let a response redirect a credential elsewhere
Responses from a token-standard registry (an HTTP service, possibly a third party’s)attach only the disclosed contracts the standard’s context names, to the command the caller built; never let registry data change which factory, party or amount is exercised beyond what the standard specifies; enforce TLS verification, with certificate failures permanent
A DAR fed to the code generator (from a package repository, a participant, or a colleague)decode it without panic or unbounded memory (the decompression caps and the fuzzed decoder); generate code that cannot execute anything at build time beyond compiling; not let a package name or identifier become code injection in the emitted crate
Rows in a PQS store, or the store’s connectionbind every predicate as a parameter, never interpolate; require TLS when asked for it; decode a hostile row into an error, not a panic
The process’s logs and Debug outputnever print a bearer token, an OAuth secret, a private key, or a mutual-TLS key (the Debug implementations are hand-written for this and tested)
Time and the network (a lost response, a retried request)never submit a command twice for one intent: change-id de-duplication on retry, and a retried execute of a signed transaction recognised as its own duplicate

Interactive submission deserves its own line: the participant never holds the key, so what the Signer is asked to sign decides everything. Under hashing scheme V2 (the default, and the one the SDK asks for) the SDK recomputes the hash from the transaction and metadata the participant returned (canton_ledger::hashing, checked against Canton’s own vectors) and refuses a response whose hash is not that; a caller that asks for another scheme explicitly signs the participant’s hash as returned. A signature is bound to the party and the hashing scheme it was made under. The same holds for onboarding an external party: the multi-hash over the onboarding topology transactions is recomputed, and each transaction is decoded and must be one of the key’s own namespace delegation, a key mapping to that key, or a hosting with confirmation or observation rights.

What the auditor is asked for

  1. A review of the crates above against the threat model: memory safety at the boundaries (#![forbid(unsafe_code)] holds across the workspace, so the question is logic, not UB), credential handling, TLS configuration, the retry and de-duplication logic, the code generator as a parser of untrusted input, and the token-standard workflows’ handling of registry data.
  2. Findings rated critical / high / medium / low / informational, each with a reproduction.
  3. Critical and high findings are remediated before the report is published; medium and low are remediated or accepted with a written rationale.
  4. A remediation summary, published in this directory under Apache-2.0 with the report, and linked from the milestone issue.

Out of scope

  • Canton, Splice, Scribe, Keycloak and the networks themselves.
  • The generated binding crates as text (see above).
  • The example programs and the test suites, except where a test double shapes production behaviour.
  • Denial of service against a participant by a client that is itself compromised; the SDK is not a rate limiter.
  • Cryptographic primitives: ring and rustls are used, not implemented.

Inputs the auditor gets

  • The repository at the tag, with CI green on Linux, macOS and Windows.
  • This document, the compatibility matrix, the Ledger Client Standard map, and the verification records under docs/verification/.
  • The previous independent review of milestone 1 by Equilibrium (posted on the milestone-1 issue), its regression branch, and the fixes, so that ground is not covered twice.
  • A LocalNet recipe (the README’s testing section) and, on request, a party on a DevNet validator.

Open with the subcommittee

  • The auditor: a quote for this scope is in hand and named in the milestone-3 submission; alternatives welcome.
  • The budget, pass-through and separate from the base grant, as the proposal sets out.
  • Whether the review starts before or after the milestone-3 vote, given the hard deadline of 6 months from grant approval.
  • Anything the subcommittee wants added to the threat model.

Token-standard examples: live runs

Three runs are on record: a cn-quickstart LocalNet (below), the Canton Network DevNet (further down, dated 2026-09-22), and the reviewer’s path on a Canton Builder Tool LocalNet (at the end, same day).

The verification clause of the milestone-3 proposal asks for a V1 transfer and a V2 Account-based transfer/allocation exercised end to end. The five examples under crates/canton-token/examples are that exercise, and this file is their output from the most recent run, kept verbatim so the offsets and update ids can be checked against the participant that produced them.

Environment

Date2026-09-07
Networkcn-quickstart LocalNet (Splice 0.6.11 images), SV_PROFILE=on
ParticipantCanton 3.5.7, the App Provider node: gRPC :3901, JSON :3975
Registrythe super-validator’s scan on :5012, reached through a socat forward
InstrumentAmulet (Canton Coin), which declares every V1 and V2 token-standard API
Senderthe App Provider party (app_provider_quickstart-…)
Receiverthe App User party (app_user_quickstart-…)
Executor (allocation only)the super-validator party (sv::…)
AuthenticationKeycloak OIDC client credentials, as the live tests use

Every example read its input holdings from the ledger through canton_token::holdings::spendable — no contract id was supplied by hand — and submitted with exactly the contracts the registry named for disclosure.

Commands

export CANTON_TEST_ENDPOINT=http://localhost:3901
export CANTON_TEST_TOKEN_URL=http://keycloak.localhost:8082/realms/AppProvider/protocol/openid-connect/token
export CANTON_TEST_CLIENT_ID=app-provider-backend CANTON_TEST_CLIENT_SECRET=…
export CANTON_TOKEN_REGISTRY_URL=http://localhost:5012
export CANTON_TOKEN_SENDER='app_provider_quickstart-…::1220…'
export CANTON_TOKEN_RECEIVER='app_user_quickstart-…::1220…'
export CANTON_TOKEN_EXECUTOR='sv::1220…'
export CANTON_TOKEN_INSTRUMENT=Amulet CANTON_TOKEN_AMOUNT=1.0

cargo run -p canton-token --example v1_transfer
cargo run -p canton-token --example v2_transfer
cargo run -p canton-token --example v2_allocate

And the JSON-only package read that issue #2 asked for, against the same participant with nothing but its JSON port:

CANTON_JSON_ENDPOINT=http://localhost:3975 \
CANTON_TEST_TOKEN_URL=… CANTON_TEST_CLIENT_ID=… CANTON_TEST_CLIENT_SECRET=… \
  cargo run -p canton-ledger --example json_packages

Output

v1_transfer — V1 transfer (CIP-56)

2026-09-07T19:52:14Z, exit 0

registry admin: DSO::1220c684772525a9bbf71cfe5669f24944d2ee9cbb34427f3d74ea3a404320168d96
instrument:     Canton Coin (CC), 10 decimals
holdings:       1 unlocked holding(s) of Amulet read from the ledger
kind:           offer — this will NOT settle until the receiver accepts it
disclosing:     4 contract(s) the registry named
committed 1220a2f8c620e1ec325c2194da01c700a87013a465d03371dc8e3765f7dc6848bbcb at offset 67684 with 6 event(s)

v2_transfer — V2 transfer over accounts (CIP-0112)

2026-09-07T19:52:41Z, exit 0

registry admin: DSO::1220c684772525a9bbf71cfe5669f24944d2ee9cbb34427f3d74ea3a404320168d96
instrument:     Canton Coin (CC), 10 decimals
from account:   "" / to account: ""
holdings:       1 unlocked holding(s) of Amulet read from the ledger
kind:           offer — this will NOT settle until the receiver accepts it
disclosing:     4 contract(s) the registry named
committed 122024fb9a369c0d2556058ed3033c8334ae4800673a73b01af1bc1b2d77b03a7e1c at offset 67690 with 6 event(s)
  holdings change on 00f709626156fc630d8e5278b8c9b514e2511609ae117e747a29be08e80ded64e9ca1212203b018402c21cf5e15e4c01c8800e446a1e96456d6a1de77756fa6153207ce301 (node 9): 1 spent, 2 produced, 2 leg(s)

v2_allocate — V2 allocation, three parties (CIP-0112)

2026-09-07T19:52:48Z, exit 0

registry admin: DSO::1220c684772525a9bbf71cfe5669f24944d2ee9cbb34427f3d74ea3a404320168d96
sender:         app_provider_quickstart-dimakozoliy-1::12204a2925a78214d3a4c81f3b425f89894a42858b9e8f13a8427c68513232cdf640
receiver:       app_user_quickstart-dimakozoliy-1::12209a0cd86271e87116bd1f059b0392dd5a7b09b93681b488082ed8c5a808fc25e3
executor:       sv::1220932f0e5664f46da8c8fcc36a6b1f8df822df6d70188d0090878b7e4eed8f2088
holdings:       1 unlocked holding(s) of Amulet read from the ledger
disclosing:     2 contract(s) the registry named
allocated:      122011c124d3a1420d7ce72e172cc8b6934a006eb3dda43e4ea7a9ace9e391756265 at offset 67693 with 6 event(s)

the executor settles this batch with:
  settlement id: dvp-1788810771450019
  executor:      sv::1220932f0e5664f46da8c8fcc36a6b1f8df822df6d70188d0090878b7e4eed8f2088
  v2::settle_batch(&registry, settlement, transfer_legs, allocations, vec![executor])
  created:       00a352050a612e0175c58de115803585cec450a9d3c1cf913d648a8768de587dcfca12122077deccc45c651af83eb4567e5dd30d7f9916991301db8a71e5501a657a685858
  created:       00eac6bef2d86eed2ee4eb33362095ee5f1d09471e0341418d9080f8a3206e8fdeca121220e939f334b3bb9c3f71905bfc0d783f4ddfc2531591795dd13cffc7d9f8b14f16
  created:       00ba2dedc8f522a97a2a24c64f4854e45f321aa6ae55a408d49e11c328a9dc0a93ca121220eb6dea4ffba2706a8cc14afb6320131e8518003036939a1e734d66b090a0ecba

json_packages — JSON-only package read (issue #2)

2026-09-07T19:51:46Z, exit 0

participant:    http://localhost:3975 (JSON Ledger API 3.5.7)
packages known: 63
de2cc2f90eb523414ff54e899951dadd8789a4c07e0f71f6d6c9eaf57d412a54: Registered

What the output shows

ClauseEvidence
V1 transfer settles end to endv1_transfer: the factory resolved against the registry, four disclosed contracts attached, committed at offset 67684 with 6 events. The kind is offer, so the receiver’s accept completes it — the receiver’s move, from their own credentials
V2 Account-based transferv2_transfer: the same over accounts, committed at offset 67690; events::holdings_changes read the committed transaction back as one holdings change — 1 spent, 2 produced, 2 legs
V2 allocationv2_allocate: sender, receiver and executor are three distinct parties; allocated at offset 67693, 3 contracts created, and the settlement the executor completes is printed with its id. Settling is deliberately not run from the sender’s process
Choice context and disclosureevery run lists the contracts the registry named and submitted with them; the transaction would not have interpreted without them

Amulet is a V2 implementation exercised as one. Whether that satisfies the clause’s words “V2 reference token” is recorded as an open question for the subcommittee in the compatibility matrix, not decided here.


DevNet, 2026-09-22

The same three examples, plus the withdrawal that releases the allocation, against a validator on the Canton Network DevNet — the network the proposal’s verification clause names. Every submission was direct: the receiving party holds a transfer pre-approval, so both transfers settled on submission with no accept step, which is the clause’s “settles end to end” taken literally.

Environment

Date2026-09-22, 07:32–07:34 UTC
NetworkCanton Network DevNet, synchronizer global-domain::1220be58…
Participantnodejumper-dev-1, Canton 3.5.17, Splice validator 0.8.1 (Nodejumper’s node)
Ledger APIgRPC over TLS on :443; JSON Ledger API over HTTPS
AuthenticationKeycloak, realm canton-devnet: a user token with actAs on the sender party and nothing else
Registrythe public DevNet Scan of SV-1, https://scan.sv-1.dev.global.canton.network.sync.global, unauthenticated
InstrumentAmulet (Canton Coin); the DevNet registry declares all ten V1 and V2 APIs
Sender8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd…, a wallet user on that node, funded by a tap
Receiver and executornodejumper-dev-1::1220a5cd…, the validator’s own party
Amount1 CC per operation

The registry’s OpenAPI documents at Splice 0.8.1 were diffed against the vendored 0.6.11 copies before the run: six of seven are byte-identical and token-metadata-v1 differs only by the additive accountInputFieldsToShow field, which Instrument already carries.

Commands

export CANTON_TEST_ENDPOINT=https://ledger-grpc.validator.devnet.canton.nodejumper.io
export CANTON_TEST_JSON_ENDPOINT=https://ledger-api.validator.devnet.canton.nodejumper.io
export CANTON_TOKEN_REGISTRY_URL=https://scan.sv-1.dev.global.canton.network.sync.global
export CANTON_TOKEN=…                # a bearer token for the sender's user
export CANTON_TOKEN_SENDER='8f2bb33b-…::1220a5cd…'
export CANTON_TOKEN_RECEIVER='nodejumper-dev-1::1220a5cd…'
export CANTON_TOKEN_EXECUTOR='nodejumper-dev-1::1220a5cd…'
export CANTON_TOKEN_INSTRUMENT=Amulet CANTON_TOKEN_AMOUNT=1.0

cargo run -p canton-token --example v1_transfer
cargo run -p canton-token --example v2_transfer
cargo run -p canton-token --example v2_allocate
cargo run -p canton-token --example v2_withdraw_allocation

Each was run first with CANTON_TOKEN_DRY_RUN=1 — the command built against the real registry, with its context and disclosures, and not submitted — and then for real.

Output

v1_transfer — V1 transfer (CIP-56), direct

2026-09-22T07:32:26Z, exit 0

registry admin: DSO::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a
instrument:     Canton Coin (CC), 10 decimals
holdings:       1 unlocked holding(s) of Amulet read from the ledger
kind:           direct — completes on submission
disclosing:     6 contract(s) the registry named
committed 12203d9608d50269325b406975fc6445c1956acd845dc30185bab22db21b3979fb3d at offset 3316298 with 7 event(s)

v2_transfer — V2 transfer over accounts (CIP-0112), direct

2026-09-22T07:32:33Z, exit 0

registry admin: DSO::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a
instrument:     Canton Coin (CC), 10 decimals
from account:   "" / to account: ""
holdings:       1 unlocked holding(s) of Amulet read from the ledger
kind:           direct — completes on submission
disclosing:     6 contract(s) the registry named
committed 12202e8ddcd542ff100bd16850a6d2b97f4cced6cf25246ea1c55fb77baf2fe5579e at offset 3316304 with 7 event(s)
  holdings change on 00435bc9becd27689198d8123677d28c1b23061da6b0716fc3c24e02deb657c3cbca1212206d0cde3b136ecb322fc15ff0c110882c11910cdd31009451eacdb5cb72434ecb (node 11): 1 spent, 1 produced, 1 leg(s)
  holdings change on 00435bc9becd27689198d8123677d28c1b23061da6b0716fc3c24e02deb657c3cbca1212206d0cde3b136ecb322fc15ff0c110882c11910cdd31009451eacdb5cb72434ecb (node 12): 0 spent, 1 produced, 1 leg(s)

v2_allocate — V2 allocation (CIP-0112)

2026-09-22T07:32:44Z, exit 0

registry admin: DSO::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a
sender:         8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd
receiver:       nodejumper-dev-1::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd
executor:       nodejumper-dev-1::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd
holdings:       1 unlocked holding(s) of Amulet read from the ledger
disclosing:     2 contract(s) the registry named
allocated:      12201e70b86ba0ef478fc6738c62d85d13a38e2df1318671db6b0ae560e751afe3fd at offset 3316319 with 6 event(s)

the executor settles this batch with:
  settlement id: dvp-1790062365710383
  executor:      nodejumper-dev-1::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd
  v2::settle_batch(&registry, settlement, transfer_legs, allocations, vec![executor])
  created:       00f536ec3917d4ab54acefc4aad06ad72cfd9690fda1c157468475041016c63b72ca121220776cab3d3e2c5bea1d1fe570310fb30ec1483b00a1d50b1825c5716d2eb49af9
  created:       009badac1d7fc8a59777ae65c71d9d319a16598b20ec4321c9b148cd7c2cfc57a8ca12122086e787c4fc08e546ebe24d6d88f9879382ede8fa443a8d44ebe8b8fe7897f925
  created:       00594cb3a8b1a5d3ecdbfca3c40a20c37eb5e3bf748d4ebd75235a0ffbdb0ae555ca121220ca0f4d05081cc81706f2df0879af716cc2db3460e358f70dcacc8c7887334b7e

v2_withdraw_allocation — V2 allocation withdrawn by the sender

2026-09-22T07:33:39Z, exit 0

sender:      8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd
allocations: 1 active
  00594cb3a8b1a5d3ecdbfca3c40a20c37eb5e3bf748d4ebd75235a0ffbdb0ae555ca121220ca0f4d05081cc81706f2df0879af716cc2db3460e358f70dcacc8c7887334b7e
    settlement dvp-1790062365710383 by nodejumper-dev-1::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd, 1 holding(s) reserved
withdrawing 00594cb3a8b1a5d3 (settlement dvp-1790062365710383): disclosing 4 contract(s) the registry named
withdrawn:   12209b5d69f163eca284c4f2c04cfcbb030de8792012a5e37c3c8b64294f3a7a51a3 at offset 3316364 with 7 event(s)

Read back from the participant by update id

Every update id above was read back from the node over the JSON Ledger API (POST /v2/updates/update-by-id) after the run. The record times are the synchronizer’s.

Update idOffsetRecord timeEvents visible to the sender
12203d9608d50269325b…33162982026-09-22T07:32:29.748296Z2
12202e8ddcd542ff100b…33163042026-09-22T07:32:34.873070Z2
12201e70b86ba0ef478f…33163192026-09-22T07:32:47.179492Z4
12209b5d69f163eca284…33163642026-09-22T07:33:42.266115Z3

The public Scan’s transaction API answers 403 to unauthenticated callers on DevNet, so the ids are verifiable from any DevNet participant rather than from a browser. The sender’s holdings afterwards: 837.2655239056 CC and the 1 CC the withdrawal released, both unlocked — 840 minus the two transfers, nothing left reserved.

Executor settlement, the same day

The allocation above was withdrawn, not settled: the executor is the validator’s party and this run holds no token for it. settle_batch — the executor’s move, and the proposal’s “allocation/executor settlement” — was then exercised with the sender as executor, in two attempts that together say what a V2 settlement requires.

First attempt, refused by the settlement factory. An allocation naming the sender as executor, one leg from the sender to the validator’s party. Amulet’s SettlementFactory_SettleBatch refused it at interpretation:

executor:    8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd
allocations: 1 naming it as executor
settlement dvp-1790063359274031: 1 allocation(s), 1 leg(s)
  leg leg-1: 8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd -> nodejumper-dev-1::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd : 1.0000000000 Amulet
  disclosing 3 contract(s) the registry named
Error: Status(Status { code: FailedPrecondition, message: "DAML_FAILURE(9,5085fbf8): Interpretation error: Error: User failure: UNHANDLED_EXCEPTION/DA.Exception.GeneralError:GeneralError (error category 9): 'missing authorizations' is not equal to 'empty set'.\nmissing authorizations: Set [(Account {owner = Some 'nodejumper-dev-1::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd', provider = None, id = \"\"},TransferLegSide {transferLegId = \"leg-1\", side = ReceiverSide, otherside = Account {owner = Some '8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd222348403b3db750ba80ddbd...", details: … }

That is the standard, not a bug: a V2 leg needs both sides authorised — the receiver consents with a ReceiverSide allocation of its own — and a transfer pre-approval does not stand in for it. This allocation was withdrawn (update 1220f4a7…, offset 3317173).

Second attempt, settled. A leg whose sender, receiver and executor are the same party, so one allocation carries both sides and one token can execute. Degenerate as a delivery-versus-payment, but it drives the whole path: the allocation factory, the settlement factory’s context and disclosures, and settle_batch committing on the DevNet synchronizer.

v2_allocate — both sides of the leg in one allocation

2026-09-22T07:50:32Z, exit 0

receiver:       8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd
receiver = sender: authorising both sides of the leg in this allocation
allocated:      1220a959f224d586e432b0e7684cd04b6d204356b33f6ecf3d334d0ecbbb8e59ea5b at offset 3317197 with 2 event(s)
  settlement id: dvp-1790063434475117

v2_settle — the executor settles the batch

2026-09-22T07:50:54Z, exit 0

executor:    8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd
allocations: 1 naming it as executor
settlement dvp-1790063434475117: 1 allocation(s), 1 leg(s)
  leg leg-1: 8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd -> 8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd222348403b3db750ba80ddbd0c3f18a5692b425273d150c1efff5ceb63bd : 1.0000000000 Amulet
  disclosing 2 contract(s) the registry named
  settled: 1220b829f7b412aedd5215d77f006b4d6fdd54696cfaf21fe1093c18754327299d69 at offset 3317221 with 4 event(s)

Read back by update id:

Update idOffsetRecord timeEvents visible to the sender
1220424177cbc89173b0…33171292026-09-22T07:49:20.440389Z4
1220f4a77863cb1ad1ea…33171732026-09-22T07:50:15.185034Z3
1220a959f224d586e432…33171972026-09-22T07:50:35.177075Z1
1220b829f7b412aedd52…33172212026-09-22T07:50:57.874686Z1

The sender’s holdings afterwards: one unlocked holding of 838.2655239056 CC — 840 minus the two transfers to the validator, the settled coin back with its owner, nothing reserved.

Live suites against the same node

With CANTON_TEST_REQUIRE_LIVE=1, so a suite that could not reach the node fails rather than skips:

  • canton-token live suite against the DevNet registry: 5 passed — the registry describes itself and its instruments as the standard says, and paging works against a real Scan.
  • canton-ledger live suite, the subset a node without the test DAR can run: 6 passed — version and health over gRPC, version and ledger end over JSON, the JSON-only package read (288 packages), the JSON party reads (the participant id, the sender’s own party), a bad token is an HTTP error.

The submission-based ledger and interactive-submission suites need the quickstart-licensing package on the participant and were not run here; they run against the LocalNet above.

What the run shows against the clause

ClauseEvidence
A V1 transfer settles end to end on DevNetcommitted at offset 3316298, kind direct, read back with record time 07:32:29 UTC
A V2 Account-based transfer/allocation exercised against Canton Coin’s V2 path on DevNettransfer at 3316304 with the holdings change parsed from the committed transaction; allocation at 3316319 naming the validator as executor, withdrawn at 3316364; allocation at 3317197 settled by its executor at 3317221
Allocation/executor settlementsettle_batch committed through Amulet’s settlement factory at 3317221, after the factory had refused a one-sided leg — the refusal is on record above
Choice context and disclosure, against a production registrysix disclosed contracts per transfer, two per allocation, four per withdrawal, all named by the DevNet Scan

The clause names two V2 targets, joined by “and”: the V2 reference token, and Canton Coin’s V2 path on DevNet. This run covers the second. The first is not covered here: the reference token is TestTokenV2 from Splice’s splice-token-standard-v2-test package, which Splice exercises by Daml script against a simulated registry; it has no deployment on DevNet, and the separate V2 DevNet that once hosted it is retired. Closing that half means either standing the reference token up ourselves — its DAR on a participant, with a registry for it — or the subcommittee agreeing that the deployed V2 implementation stands in for a reference token that is not deployed anywhere.


The reviewer’s path: Canton Builder Tool, 2026-09-22

The “How to verify” steps of the milestone comment, run verbatim from a fresh clone of the branch (git clone, then the commands as written) against a Canton Builder Tool LocalNet — the environment the Foundation’s reviewer used for the earlier milestones. Nothing beyond canton builder start, one tap in the App Provider wallet, and the commands.

LocalNetCanton Builder Tool, Splice 0.6.11 images, Canton 3.5.7; auth self-signed (HS256 tokens from canton builder token, whose user holds ParticipantAdmin)
Registrythe LocalNet’s own Scan, http://scan.localhost:4000
Partyapp_provider_builder-localnet-1::122050a3…, sender, receiver and executor at once (kind: self)
External signingthe built-in Ping template every participant ships, created by the freshly onboarded external party
PQSScribe 3.5.4 from tools/pqs/compose.yaml, reading as the same token, after wait-ready.sh
### step 2: cargo test --workspace --all-features
test lower::tests::a_daml_name_rust_cannot_spell_is_skipped_not_panicked_on ... ok
passed: 600 failed: 0
### step 3: conformance
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 5 tests
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 51 tests
test result: ok. 51 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
capabilities: 51  gaps: 0
### step 4: external signing
committed 122070b591b0a0b519fa1d06c4809003ae6f0d063b48f72186ba309276041f4f31e1 at offset 303 with 1 event(s)
test external_commands__prepare_sign_execute_commits_a_transaction ... ok
test signing__an_external_party_is_onboarded_by_signing_its_own_topology ... ok
rejected as expected: grpc status Client specified an invalid argument: FAILED_TO_EXECUTE_TRANSACTION(8,bbc5eeef): The participant failed to execute the transaction: Received 0 valid signatures from d
test signing__a_signature_from_another_key_is_rejected ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.91s
### step 5: json_packages
participant:    http://localhost:3975 (JSON Ledger API 3.5.7)
packages known: 60
de2cc2f90eb523414ff54e899951dadd8789a4c07e0f71f6d6c9eaf57d412a54: Registered
### step 6: token standard
test the_registry_says_who_administers_its_instruments ... ok
test paging_through_instruments_works_against_a_real_registry ... ok
test the_registry_declares_which_token_standard_versions_it_serves ... ok
test an_instrument_this_registry_does_not_issue_is_none_rather_than_an_error ... ok
test the_instruments_decode_as_the_standard_describes_them ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
--- v1_transfer
registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
instrument:     Amulet (AMT), 10 decimals
holdings:       1 unlocked holding(s) of Amulet read from the ledger
kind:           self — sender and receiver match
disclosing:     4 contract(s) the registry named
committed 1220eeabaf4c53d837e1ee9ecd24e69e6c480b1c8e26763f3bd7f83685277d520705 at offset 311 with 5 event(s)
exit=0
--- v2_transfer
registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
instrument:     Amulet (AMT), 10 decimals
from account:   "" / to account: ""
holdings:       2 unlocked holding(s) of Amulet read from the ledger
kind:           self — sender and receiver match
disclosing:     4 contract(s) the registry named
committed 122021d543f64f15dca59f5d0db3c6a923b622825b904ea06e266c9e7e91dfcdbed8 at offset 314 with 6 event(s)
  holdings change on 00bb34aac538c58ba3ca4a08d848d709d17dec3a0f3cf2ec32c124d01db1c2d357ca121220423f5b875fe0a0747bf01aaef96096929245507ad6b089128fd66028710e9af6 (node 12): 2 spent, 2 produced, 2 leg(s)
exit=0
--- v2_allocate
registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
sender:         app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
receiver:       app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
executor:       app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
receiver = sender: authorising both sides of the leg in this allocation
holdings:       2 unlocked holding(s) of Amulet read from the ledger
disclosing:     2 contract(s) the registry named
allocated:      1220ea0fae9279d586d46b78a5382a195af5aa315ee8168a26cf1c3b8a6183aa7a1b at offset 317 with 2 event(s)

the executor settles this batch with:
  settlement id: dvp-1790073600615421
  executor:      app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
  v2::settle_batch(&registry, settlement, transfer_legs, allocations, vec![executor])
  created:       00b55f1fb85f23d86f6816e6ecbb1ca75bd35b052497575d1273a26c0ededaec3eca121220332df3d5975bcf7c4f8fceca7abeed58adf50c249a71cd342b4d3fa27700803e
exit=0
--- v2_withdraw_allocation
sender:      app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
allocations: 1 active
  00b55f1fb85f23d86f6816e6ecbb1ca75bd35b052497575d1273a26c0ededaec3eca121220332df3d5975bcf7c4f8fceca7abeed58adf50c249a71cd342b4d3fa27700803e
    settlement dvp-1790073600615421 by app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e, 0 holding(s) reserved
withdrawing 00b55f1fb85f23d8 (settlement dvp-1790073600615421): disclosing 0 contract(s) the registry named
withdrawn:   1220b6c66e3c202d0ac7b55fa693c1e95e5486302408c41422e73cbe9d9eafa4190b at offset 320 with 2 event(s)
exit=0
--- v2_allocate
registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
sender:         app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
receiver:       app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
executor:       app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
receiver = sender: authorising both sides of the leg in this allocation
holdings:       2 unlocked holding(s) of Amulet read from the ledger
disclosing:     2 contract(s) the registry named
allocated:      12206f7aeb40c08e1dbf9f512c8dfaec2ee02c5648b0309478834984f56b500189a6 at offset 323 with 2 event(s)

the executor settles this batch with:
  settlement id: dvp-1790073604041114
  executor:      app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
  v2::settle_batch(&registry, settlement, transfer_legs, allocations, vec![executor])
  created:       00a85b1e5b537e01969d0f19cad1355c584cbdb341594c7f8b91538d22be0f0a88ca121220c6e240b34c502e153e2da2d363c2b6ed423bd8634284ee731eb3890f79426ad6
exit=0
--- v2_settle
executor:    app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
allocations: 1 naming it as executor
settlement dvp-1790073604041114: 1 allocation(s), 1 leg(s)
  leg leg-1: app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e -> app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e : 1.0000000000 Amulet
  disclosing 2 contract(s) the registry named
  settled: 12206f9fa56dbc7208b1534bfed792427d3d7314cb22db4bbaa1bf2f5ba69aeb9092 at offset 326 with 4 event(s)
exit=0
### step 7: pqs
 Container pqs-scribe-1 Started
pqs ready at offset 326
test a_refused_connection_is_not_retried_forever ... ok
test pqs_reports_how_far_it_has_ingested ... ok
test an_ordered_comparison_is_a_statement_postgres_accepts ... ok
active Amulet: 2
test a_contract_read_from_postgres_is_the_generated_type ... ok
test a_contract_is_found_by_id ... ok
test containment_and_party_columns_work_against_the_real_schema ... ok
test the_acs_can_be_read_as_of_an_offset ... ok
test a_payload_predicate_filters_in_the_database ... ok
test a_connection_the_store_closes_is_replaced_on_the_next_read ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.34s
 Network pqs_default Removed

Every step passed as written. Step 4 is the one to read closely: the onboarded party committed a create and the participant refused a signature from another key. An earlier run of the same day reported the same three tests green while two of them had quietly returned for want of an application package; that silent return is gone, which is what CANTON_TEST_REQUIRE_LIVE exists to guarantee.

The reviewer’s path at v0.3.1: Canton Builder Tool, 2026-09-29

The “How to verify” steps of the Milestone 3a comment on the tracking issue, run verbatim from a fresh clone at the v0.3.1 tag (a32f6f3) against the same Canton Builder Tool LocalNet as above (Canton 3.5.7), with the App Provider party as sender, receiver and executor. Step numbers are the comment’s. Step 1 is the cargo add of the published crates into a new project; the target directory was warm, hence the short build time.

### 3a verbatim run 2026-09-29T13:13:56Z
### step 1
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
      Adding canton v0.3.1 to dependencies
      Adding canton-token v0.3.1 to dependencies
      Adding canton-signer v0.3.1 to dependencies
      Adding canton-pqs v0.3.1 to dependencies
      Adding canton-splice-api-token-holding-v2 v0.3.1 to dependencies
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.14s
  exit=0
  versions:   21 v0.3.1
### step 2
  at v0.3.1 a32f6f3
test lower::tests::a_daml_name_rust_cannot_spell_is_skipped_not_panicked_on ... ok
  tests passed: 632 failed: 0
  conformance: test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  conformance: test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  conformance: test result: ok. 51 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s
  conformance: test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  conformance: test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  capabilities: 51  gaps: 0
### step 3
test external_commands__prepare_sign_execute_commits_a_transaction ... ok
test signing__a_signature_from_another_key_is_rejected ... ok
test signing__an_external_party_is_onboarded_by_signing_its_own_topology ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.68s
### step 4 (no tap: the party already holds Amulet)
  party: app_provider_builder-localnet-1::…
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s
  --- v1_transfer
    registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
    instrument:     Amulet (AMT), 10 decimals
    holdings:       1 unlocked holding(s) of Amulet read from the ledger
    kind:           self — sender and receiver match
    disclosing:     4 contract(s) the registry named
    committed 12207e951759258c98f480ae38296a36d925aeae3afbb7c6386b155c5ca1ec44787e at offset 12769 with 5 event(s)
  --- v2_transfer
    registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
    instrument:     Amulet (AMT), 10 decimals
    from account:   "" / to account: ""
    holdings:       2 unlocked holding(s) of Amulet read from the ledger
    kind:           self — sender and receiver match
    disclosing:     4 contract(s) the registry named
    committed 122051d653917109557bf71b2dc07d0d9e6024128508690813823cc7d94bb192c459 at offset 12772 with 6 event(s)
      holdings change on 00fc1b5219b7b8dba23791792ef51f06a2795e57cf44115214ee00fedc05a08064ca121220ff76501f926ceec61b4227ab711abcd5f43499dc73511028817
  --- v2_allocate
    registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
    sender:         app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    receiver:       app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    executor:       app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    receiver = sender: authorising both sides of the leg in this allocation
    holdings:       2 unlocked holding(s) of Amulet read from the ledger
    disclosing:     2 contract(s) the registry named
    allocated:      1220a5da7fbc468fe732712ea20ae879fd79855be0c5d6991935acceb551cdef405d at offset 12775 with 2 event(s)
    the executor settles this batch with:
      settlement id: dvp-1790687799143875
      executor:      app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
      v2::settle_batch(&registry, settlement, transfer_legs, allocations, vec![executor])
      created:       00b77ba11301519e41621c50427bba4def60271951187d334af92780b84121d888ca121220826e55e1acfe364e1ee38e2e362b340a324f1c15a4666d75bee860d
  --- v2_withdraw_allocation
    sender:      app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    allocations: 1 active
      00b77ba11301519e41621c50427bba4def60271951187d334af92780b84121d888ca121220826e55e1acfe364e1ee38e2e362b340a324f1c15a4666d75bee860d89e3cefe1
        settlement dvp-1790687799143875 by app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e, 0 ho
    withdrawing 00b77ba11301519e (settlement dvp-1790687799143875): disclosing 0 contract(s) the registry named
    withdrawn:   122010ec311ee14fd721884a3630f21b52773b0f4a9a7c6e46e94fd858e0da3261c0 at offset 12778 with 2 event(s)
  --- v2_allocate
    registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
    sender:         app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    receiver:       app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    executor:       app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    receiver = sender: authorising both sides of the leg in this allocation
    holdings:       2 unlocked holding(s) of Amulet read from the ledger
    disclosing:     2 contract(s) the registry named
    allocated:      122060392d80bd84162d78a66a99bf1a27754531d1553defcc4e5c3a2a9722ea7b83 at offset 12781 with 2 event(s)
    the executor settles this batch with:
      settlement id: dvp-1790687804433583
      executor:      app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
      v2::settle_batch(&registry, settlement, transfer_legs, allocations, vec![executor])
      created:       008ea96ea80011a23fac9b9da71d829f5cfcb4e02006b7ff50c69992f4fefa835cca121220ad9135325532c99ff85d559aa8f8b80bff293f5d691769655382f75
  --- v2_settle
    executor:    app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    allocations: 1 naming it as executor
    settlement dvp-1790687804433583: 1 allocation(s), 1 leg(s)
      leg leg-1: app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e -> app_provider_builder-localne
      disclosing 2 contract(s) the registry named
      settled: 1220dc87deebeb1be4fc3832672237cf6bd6685f5746db5c8066bfb69afc6069dd49 at offset 12784 with 4 event(s)
### step 5
 Container pqs-scribe-1 Started
pqs ready at offset 12784
test a_refused_connection_is_not_retried_forever ... ok
test pqs_reports_how_far_it_has_ingested ... ok
test a_contract_read_from_postgres_is_the_generated_type ... ok
test an_ordered_comparison_is_a_statement_postgres_accepts ... ok
test a_payload_predicate_filters_in_the_database ... ok
test containment_and_party_columns_work_against_the_real_schema ... ok
test a_contract_is_found_by_id ... ok
test the_acs_can_be_read_as_of_an_offset ... ok
test a_connection_the_store_closes_is_replaced_on_the_next_read ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.34s
### done 2026-09-29T13:17:30Z

Every step passed as written; the five examples printed the kind the registry answered, the disclosed-contract count and their update id and offset, and v2_settle settled the allocation the step before created.

DevNet, 2026-09-29, at v0.3.1

The same fresh clone, run against Nodejumper’s DevNet validator (the participant reported Ledger API 3.5.19 that day; Splice 0.8.1) with the public SV Scan as the registry and the validator’s own party (nodejumper-dev-1::1220a5cd…) as sender, receiver and executor.

Suite / exampleResult
canton-ledger live36 passed
interactive_live (external signing)3 passed
canton-token registry live5 passed
canton-pqs live, Scribe 3.5.4 over TLS following the participant from its latest offset9 passed, store ready at offset 3874307
submit_and_read, recover_a_submission, canton-samplecommitted; the duplicate refused as DUPLICATE_COMMAND and the original outcome recovered; the typed loop on both transports
json_packages / fetch_packages290 packages known, 290 written

Read back from the participant by update id

The six example runs, read back over the JSON Ledger API (POST /v2/updates/update-by-id) after the run; record times are the synchronizer’s.

ExampleUpdate idOffsetRecord time
v1_transfer1220ebc554e79602ca58b5cede1a60bf28a05eead6ee625c4e5590b576a1e0873b6d38741472026-09-29T10:09:00.343272Z
v2_transfer12203fbcf8faeeb8113006e9575dd1ad9d8c7d2dc50f4b06acb2778eb76f186233d538741602026-09-29T10:09:05.583538Z
v2_allocate122066fb28a402e4d80fcd1d5cad14221fb871fe33fcea38b55de6d9557219e8b6e238741692026-09-29T10:09:11.593049Z
v2_withdraw_allocation1220d6cddeee588d4a5573fb8caf152397a0168f94bc482e190d3e327cabb6bba22b38741752026-09-29T10:09:17.353045Z
v2_allocate122077f9e709bb1fa02111f62b75dfd3a4ae108de95d999dd8942dee89b9db923eff38741812026-09-29T10:09:22.243755Z
v2_settle12209cdb5421ab0a1791651d6a637726abc5e1505caef97fc1edf94a0311aad2461038741982026-09-29T10:09:28.453035Z

V2 settlement between three parties on three participants, 2026-10-13

The settlements above have one party as sender, receiver and executor. This run has three, each on its own participant and each submitting its own step with its own participant’s token: the sender reserves its holding, the receiver consents to be paid, and the executor settles both allocations as one batch through Amulet’s settlement factory.

LocalNetCanton Builder Tool (Canton 3.5.7) with the App User validator added (canton builder start --with app-user): three participants on one synchronizer
Registrythe LocalNet’s own Scan, http://scan.localhost:4000; instrument Amulet, amount 1.0
Codemain plus the receiver’s mode of v2_allocate (CANTON_TOKEN_SIDE=receiver); the SDK library is unchanged since v0.3.1
RolePartyParticipantStep
Senderapp_provider_builder-localnet-1::122050a3…app-provider, :3901v2_allocate: the sender’s side, one holding reserved
Receiverapp_user_builder-localnet-1::12201187…app-user, :2901v2_allocate with CANTON_TOKEN_SIDE=receiver and the sender’s settlement id: the receiver’s side
Executorsv::1220e8c9…sv, :4901v2_settle: both allocations settled as one batch

Commands

export CANTON_TOKEN_REGISTRY_URL=http://scan.localhost:4000 CANTON_TOKEN_INSTRUMENT=Amulet CANTON_TOKEN_AMOUNT=1.0
export CANTON_TOKEN_SENDER='<app_provider party>' CANTON_TOKEN_RECEIVER='<app_user party>' CANTON_TOKEN_EXECUTOR='<sv party>'

# 1. the sender, on the app-provider participant
CANTON_TOKEN=$(canton builder token --validator app-provider) CANTON_TEST_ENDPOINT=http://localhost:3901 \
  cargo run -p canton-token --example v2_allocate            # prints the settlement id

# 2. the receiver, on the app-user participant
CANTON_TOKEN=$(canton builder token --validator app-user) CANTON_TEST_ENDPOINT=http://localhost:2901 \
  CANTON_TOKEN_SIDE=receiver CANTON_TOKEN_SETTLEMENT_ID='<settlement id>' \
  cargo run -p canton-token --example v2_allocate

# 3. the executor, on the sv participant
CANTON_TOKEN=$(canton builder token --validator sv) CANTON_TEST_ENDPOINT=http://localhost:4901 \
  CANTON_TOKEN_SETTLEMENT='<settlement id>' cargo run -p canton-token --example v2_settle

The balances before and after are each party’s Amulet holdings, read from its own participant’s JSON Ledger API through the Holding interface.

Output

### V2 DvP, three parties on three participants, 1e933c8 2026-10-13T11:20:38Z
  sender   app_provider_builder-localnet-1::…  (app-provider participant, :3901)
  receiver app_user_builder-localnet-1::…  (app-user participant, :2901)
  executor sv::…  (sv participant, :4901)
--- before: sender 1 holding(s), 190320.3200000000 Amulet; receiver 0 holding(s), 0 Amulet
--- 1. sender allocates its side (app-provider token)
    registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
    sender:         app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    receiver:       app_user_builder-localnet-1::12201187a79fd5e9d82a80c85a0460893193edf9adc696e37ccde66a2343d328f246
    executor:       sv::1220e8c961fae7b4034ce31a84388aeb59cc00c8089a9af03aa1cafb0649d328d861
    side:           sender
    holdings:       1 unlocked holding(s) of Amulet read from the ledger
    disclosing:     2 contract(s) the registry named
    allocated:      1220973efa56a4f1abaa2e7fa19a9749da150ab06eb54e6063a80039cbf7e8c398f9 at offset 13781 with 6 event(s)

    the executor settles this batch with:
      settlement id: dvp-1791890468907761
      executor:      sv::1220e8c961fae7b4034ce31a84388aeb59cc00c8089a9af03aa1cafb0649d328d861
      v2::settle_batch(&registry, settlement, transfer_legs, allocations, vec![executor])
      created:       00890f7477f223e750268c59f9f8c340c8a34c34f0da952ab2677c843661082cf8ca121220e2b9a78989677f876939365cf13d71e356b97241b7b4ef64db9a4122c7c2a35d
      created:       00ad91eece6966cfdcffa618aa0cf5a50d03d200ba9e8b320789989af28c16aca3ca121220b4670ec236fead19ed728943c4add0008f01b628b7f7771deaf19b3fda52fc49
      created:       007360ee16c857b9062e5490a0a7b6e86098395a89110f2b997f319365d5497fe2ca1212209f68e83c33c641059f59466a9ae706cfafd9475ef7c0f21270f7106590c19069
--- 2. receiver allocates its side of dvp-1791890468907761 (app-user token)
    registry admin: DSO::1220da56976bc0f405bfffaf268f075cfb2d0d0224f2115b214a38cf03db1b2cedad
    sender:         app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e
    receiver:       app_user_builder-localnet-1::12201187a79fd5e9d82a80c85a0460893193edf9adc696e37ccde66a2343d328f246
    executor:       sv::1220e8c961fae7b4034ce31a84388aeb59cc00c8089a9af03aa1cafb0649d328d861
    side:           receiver
    disclosing:     2 contract(s) the registry named
    allocated:      1220e7606e818594c8af1c4cb7ea7f1fc1bd405adc6d4ca743ff7427e469b7c43fc0 at offset 181 with 2 event(s)

    the executor settles this batch with:
      settlement id: dvp-1791890468907761
      executor:      sv::1220e8c961fae7b4034ce31a84388aeb59cc00c8089a9af03aa1cafb0649d328d861
      v2::settle_batch(&registry, settlement, transfer_legs, allocations, vec![executor])
      created:       00296653c978314158feffc8e08da6f023947fed7b2e43657fae6c02d4ef1556f6ca121220e293f9c24e71953a778797f12bbe3f55e56cf6d5c28037160d0074fd9f5b6858
--- 3. executor settles dvp-1791890468907761 (sv token)
    executor:    sv::1220e8c961fae7b4034ce31a84388aeb59cc00c8089a9af03aa1cafb0649d328d861
    allocations: 2 naming it as executor
    settlement dvp-1791890468907761: 2 allocation(s), 1 leg(s)
      leg leg-1: app_provider_builder-localnet-1::122050a3b417325869df2a2248607ef4d408abdd45da8d7c4e774f96c090e32cd11e -> app_user_builder-localnet-1::12201187a79fd5e9d82a80c85a0460893193edf9adc696e37ccde66a2343d328f246 : 1.0000000000 Amulet
      disclosing 3 contract(s) the registry named
      settled: 122062792e2766b8b39d77d65f16e2027586cb61a181c1107113f8efd144f5f54dfd at offset 23299 with 9 event(s)
--- after: sender 1 holding(s), 190319.3200000000 Amulet; receiver 1 holding(s), 1.0000000000 Amulet
### done 2026-10-13T11:21:14Z

Read back from each participant by update id

The settlement is one transaction on the synchronizer, so the three participants report the same record time at their own offsets.

UpdateRead asParticipantOffsetRecord time
sender’s allocation 1220973efa56a4f1abaa…senderapp-provider137812026-10-13T11:21:09.453125Z
receiver’s allocation 1220e7606e818594c8af…receiverapp-user1812026-10-13T11:21:11.201934Z
settlement 122062792e2766b8b39d…senderapp-provider137842026-10-13T11:21:13.592559Z
settlement 122062792e2766b8b39d…receiverapp-user1842026-10-13T11:21:13.592559Z
settlement 122062792e2766b8b39d…executorsv232992026-10-13T11:21:13.592559Z

The sender ends 1 Amulet lower and the receiver, which held none, holds 1 Amulet: the leg moved between two parties on two participants, settled by a third.

Maintenance

How the Canton Rust SDK is maintained, what that covers, and what it does not. The SDK is maintained by Nodejumper; its development is funded by the Canton Dev Fund (proposal).

What is maintained

Every canton-* crate published from this repository, and the dpm-codegen-rust binary (canton-codegen-cli). They release in lockstep on one version (ADR-0005).

Priorities

Work is taken in this order. These are priorities, not deadlines.

  1. Security issues, reported as SECURITY.md describes. Security fixes ship as patch releases of the current minor.
  2. Critical bugs: bugs that lose or corrupt data, submit something the caller did not ask for, or block a documented workflow on a Canton version the compatibility matrix lists. Fixes ship as patch releases of the current minor.
  3. New Canton and Splice releases that change the Ledger API, the Daml-LF schema or the token standard: re-vendoring the schema. Generated protobuf types may change in such a release, as ADR-0002 allows.
  4. Other issues. Bug reports, questions and feature requests go to GitHub issues. Triage gives an issue a label and a decision: fixed, scheduled, or declined with the reason.

How releases are made

  • Versioning. Semantic versioning, applied pre-1.0 as Cargo does: breaking changes to the public API go into a new minor and are listed first in the changelog, and a patch is meant to be compatible. CI’s semver job runs cargo semver-checks against the published baseline for the crates it lists. Generated protobuf types are exempt, per the stability policy in canton-proto.
  • Before 1.0, only the latest minor is maintained. Fixes are not backported. Changes already decided for 1.0 are recorded in ADR-0010.
  • Every release follows RELEASING.md.

What CI checks

  • Format, clippy with -D warnings on every feature combination, and the test suite on Linux, macOS and Windows.
  • On each push and pull request:
    • the declared MSRV;
    • docs built the way docs.rs builds them;
    • cargo deny (RustSec advisories, licences, sources);
    • cargo semver-checks on the crates its job lists;
    • a build with every dependency at its newest compatible version.
  • The Daml-LF decoder against the official JVM reader (the conformance oracle).
  • Nightly and on demand, the live suites against a disposable LocalNet.

The compatibility matrix lists the Canton versions each release was run against, and which suites ran on each.

What is not promised

  • No guaranteed response or fix times. The one exception, while the grant runs, is the acknowledgement of a security report in SECURITY.md.
  • No backports to earlier minors before 1.0.
  • No support for Canton versions older than the compatibility matrix lists, or for unreleased ones.

During and after the grant

While the Canton Dev Fund grant runs, maintenance is part of the funded work. After it, the SDK stays open source and is maintained on a best-effort basis, with no committed response times (the security acknowledgement included), release dates or support windows. Extended maintenance may be funded separately.

Getting help

Security Policy

Reporting a vulnerability

Please report suspected security vulnerabilities privately — do not open a public issue or pull request.

  • Email: dmitryk@nodejumper.io (subject: SECURITY canton-rust-sdk)
  • Include: affected crate(s) and version/commit, a description, reproduction steps or a proof of concept, and the impact as you assess it.

While the Canton Dev Fund grant runs, you will receive an acknowledgement within 5 business days; after it, reports are handled on a best-effort basis (see MAINTENANCE.md). We will work with you on triage, a fix, and coordinated disclosure; please give us a reasonable window to release a fix before any public disclosure. Credit is given in the release notes unless you prefer otherwise.

Scope

The canton-* crates in this repository. Vulnerabilities in Canton itself, Splice, or other Digital Asset components should be reported to their respective maintainers.

Supported versions

Until 1.0, only the latest released minor version receives security fixes. An independent security review of the client, codegen, and token crates is the pass-through item of milestone 3 of the funding proposal; its scope is drafted in docs/security/audit-scope.md, to be agreed with the Canton Foundation’s security subcommittee before the review begins, and the report and remediation summary will be published there.

Handling of secrets

The SDK never logs bearer tokens or client secrets (Debug output is redacted, and the OIDC client secret is not readable through the public API). If you find a path where credential material can leak into logs, telemetry, or errors, please report it as a vulnerability.

Contributing to the Canton Rust SDK

Thanks for your interest! Issues, bug reports, and pull requests are welcome.

Development setup

  • Rust 1.88+ (the workspace MSRV; rustup toolchain install 1.88.0).
  • No system protoc needed — the build vendors one.
  • Optional, for live integration tests: a local Canton participant via cn-quickstart LocalNet.

Before you open a PR

All of these must pass (CI enforces them):

cargo fmt --all --check
cargo clippy --workspace --all-targets --all-features   # -D warnings via workspace lints
cargo test  --workspace --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features

Guidelines:

  • Tests are not optional. New behavior ships with a unit or in-process test; anything that talks to a node also gets an env-gated live test (CANTON_TEST_* variables — see the test files for the pattern).
  • No panics in library code (unwrap/expect/indexing are denied by lints outside tests).
  • Errors: map into canton_core::Error, keep the retriable classification correct, and document # Errors on public functions.
  • SemVer: public types are #[non_exhaustive] where growth is expected; builder methods use with_*/add_* naming. Generated proto types are exempt (see the stability policy in canton-proto).
  • Keep commits focused; update CHANGELOG.md under [Unreleased].
  • Architectural decisions (anything hard to reverse: crate boundaries, stability promises, wire-facing behavior) get an ADR in docs/adr/ — add a new numbered record rather than editing an old one.
  • Versioning: all canton-* crates release in lockstep with one shared version (ADR-0005). Publishing has an order and a checklist — RELEASING.md; a crates.io version cannot be taken back, and half a family published in the wrong order leaves the rest unpublishable until the next version.
  • CI actions are pinned to a commit, with the human-readable version in a trailing comment (actions/checkout@11d5960… # v4). A tag is mutable: whoever controls the action’s repository can repoint v4 at anything, and it would run with our checkout and our secrets. To bump one, resolve the new tag (gh api repos/<owner>/<repo>/git/ref/tags/<tag> --jq .object.sha) and update both the SHA and the comment.

Security issues

Please do not open public issues for suspected vulnerabilities — see SECURITY.md.

License

By contributing you agree that your contributions are licensed under the Apache License 2.0, the license of this repository.

Releasing

All canton-* crates release in lockstep on one version (ADR-0005). Publishing is the one step here that cannot be undone — a crates.io version is permanent, and half a family published in the wrong order leaves the rest unpublishable until the next version.

Order

cargo publish builds the package to verify it, which resolves dev dependencies as well as normal ones, so a crate cannot go before anything it depends on either way. This order is derived from the manifests, not from memory:

  1. canton-core
  2. canton-lf
  3. canton-proto
  4. canton-auth
  5. canton-codegen
  6. canton-signer
  7. canton-admin
  8. canton-codegen-cli
  9. canton-daml
  10. canton-daml-stdlib
  11. canton-ledger
  12. canton-splice-api-featured-app-v1
  13. canton-splice-api-token-metadata-v1
  14. canton-splice-api-token-holding-v1
  15. canton-splice-api-token-holding-v2
  16. canton-splice-api-token-allocation-v1
  17. canton-splice-api-token-allocation-v2
  18. canton-splice-api-token-burn-mint-v1
  19. canton-splice-api-token-transfer-events-v2
  20. canton-splice-api-token-transfer-instruction-v1
  21. canton-splice-api-token-transfer-instruction-v2
  22. canton-splice-api-token-allocation-instruction-v1
  23. canton-splice-api-token-allocation-instruction-v2
  24. canton-splice-api-token-allocation-request-v1
  25. canton-splice-api-token-allocation-request-v2
  26. canton-splice-amulet
  27. canton-token
  28. canton-pqs
  29. canton-splice-wallet-payments
  30. canton
  31. canton-splice-wallet

Re-derive the order after adding a crate:

cargo metadata --format-version 1 --no-deps >/dev/null   # manifests parse
tools/publish-order.sh                                    # prints the list above

Each step waits for the index: cargo publish -p <crate> then let it appear before the next (crates.io is usually seconds; cargo publish will fail fast with “no matching package” if it is not there yet).

Before publishing

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo test --workspace --all-features
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features
  • cargo deny check all
  • cargo hack clippy --workspace --feature-powerset --no-dev-deps -- -D warnings
  • cargo semver-checks over every crate with a published baseline — the list CI’s semver job carries in .github/workflows/ci.yml (eleven at 0.2.x). A crate joins that list in the release after it first publishes; the M3 crates (canton-signer, canton-pqs, canton-token, canton-daml-stdlib, the canton-splice-api-* set) join at the release after 0.3.0. (only crates with a published baseline; add each new one after its first release)
  • The live suites against a participant, with skips made fatal: CANTON_TEST_REQUIRE_LIVE=1 cargo test -p canton-ledger --features ws --test live and the same for -p canton-admin
  • The reference app end to end on both transports: cargo run -p canton-sample
  • CHANGELOG.md: turn — unreleased into the date
  • README.md: every place that names a version — the status line, the compatibility table, and any “unreleased”/“this branch” wording. The README is frozen into each published crate’s page at publish time, so a stale line there is not fixable until the next release: grep -nE "unreleased|this branch|0\.[0-9]+\.[0-9]+" README.md
  • book/src, the docs site’s own pages: every place that names a version, such as the --version in the codegen guide’s cargo install lines: git grep -nE "0\.[0-9]+\.[0-9]+" -- book/src (reference/ and project/ there are generated from the files above and not tracked)
  • The tree is clean and tagged v<version>

After publishing

The pre-publish cargo package runs with --no-verify, and it has to: verifying builds the packaged crate, which needs its siblings at the new version to exist on crates.io, and they do not yet. So the packaged artefact is first built by whoever installs it. Do that yourself, from a directory outside this workspace so nothing resolves by path:

cargo new /tmp/consumer && cd /tmp/consumer
cargo add canton --features ws,otel,pqs-tls
cargo add canton-daml canton-splice-amulet canton-token canton-pqs canton-signer
cargo build            # the family, as a user gets it

cargo install canton-codegen-cli --root /tmp/tools
/tmp/tools/bin/dpm-codegen-rust --dar <any>.dar --out /tmp/bindings
cd /tmp/bindings && cargo build   # generated code against the published runtime

Then check every crate documented, not just built — https://docs.rs/crate/<name>/<version>/status.json reports doc_status. A crate can publish and install perfectly while its documentation page says the build failed, and that cannot be fixed without another release.

  • GitHub release on the tag, notes from the changelog section
  • docs/compatibility-matrix.md (M3 onward) names the released version

Changelog

All notable changes to the Canton Rust SDK are documented here.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. Generated protobuf types (the canton-proto crate and the proto re-exports) are exempt from SemVer — see the stability policy in canton-proto’s docs.

[Unreleased]

Documentation

  • A documentation site, built from book/ with mdBook and published to GitHub Pages: guides for getting started, local development, reading the ledger, codegen, the token standard, interactive submission, PQS, and the admin API and telemetry. The compatibility matrix, the ADRs, the changelog and the project files are copied in at build time, so the site and the repository cannot drift apart, and CI fails on a link inside it that points at nothing.
  • MAINTENANCE.md: what is maintained, in what order (security fixes, then critical bugs, then new Canton releases), how releases are made, and what is not promised.
  • SECURITY.md: a report is acknowledged within five business days while the Canton Dev Fund grant runs (was three), and on a best-effort basis after it.
  • Corrected while writing the guides:
    • turning off the facade’s default features drops the in-memory key type, not ring, which the TLS stack uses;
    • codegen runs ahead of time, not from a build script;
    • JsonClient::with_max_decoding_message_size also caps HTTP response bodies;
    • PQS Predicate::eq compares the stored text, and compare with Op::Eq does no numeric cast (only the ordered operators do), so numeric equality across scales needs Op::Ge plus Op::Le;
    • the compatibility matrix’s live-test counts and its LocalNet settlement row were stale.

[0.3.2] — 2026-10-23

The review findings filed against 0.3.1 by the C#/.NET SDK’s maintainer (#7–#12), fixed. Two behaviour changes are listed first because they can turn a call that succeeded into one that is refused. Both refuse what should not have been accepted, and the public API only grows, so they ship as a patch: cargo update brings them, and the fix for 0.3.1’s broken fresh build, to every project on 0.3.

Changed — behaviour

  • A created event must name its package to decode (#7). Template::from_created_event and from_json_created_event now compare the event’s package name with the template’s, as well as its module and entity; the package id is still not compared, so an upgraded version decodes. An event from another package that defines the same Module:Entity with a payload of the same shape used to decode as this template; it is now refused. An event with no template id, or with a package id and no package name, is refused rather than decoded on trust. Events a participant returns carry both; a hand-built event in a test may need package_name set.
  • A prepared transaction hashed under a scheme the SDK cannot check is refused unless the caller opts in (#10). Under V2 and V3 the hash is recomputed and held to the transaction; any other scheme (V4) used to be signed as the participant returned it with only a warning, and now needs Prepare::trust_participant_hash().

Added

  • Hashing scheme V3 (#10): canton_ledger::hashing::hash_prepared_transaction_v3 and hash_prepared_transaction(prepared, version), byte for byte as Canton 3.5.17 builds V3 (no encoding-version bytes; keys on create, fetch and exercise nodes; the by-key flag; lookup-by-key nodes; the metadata’s maximum record time), checked against Canton’s own V3 vectors. Interactive submission recomputes the hash under V3 as it does under V2, so a transaction that uses contract keys is no longer signed on the participant’s word. Live: an external party creates a keyed contract and exercises it by key under V3.
  • Point reads and pruning points on both transports (#12): update_by_offset, update_by_id (and _with forms taking an UpdatesRequest’s format) and latest_pruned_offsets on CantonClient and JsonClient. “No such update” is Ok(None). Error::pruned_earliest_offset names the earliest offset the participant can still serve when a read starts below its pruning point; a resumable stream yields that refusal rather than moving forward on its own.
  • Updates as SDK types (#11): canton_ledger::update, the update envelope (Update, Transaction, created, archived and exercised events, Reassignment, TopologyTransaction, OffsetCheckpoint) built from either transport, with payloads decoded by the generated type (CreatedEvent::decode) and the wire form still reachable. CantonClient::updates_typed_with, updates_resumable_typed_with and JsonClient::updates_typed_with return them. At 1.0 these take the plain names and the proto-typed reads move to *_raw (ADR-0010 §4).
  • A keyed Daml-LF 2.3 DAR under CI (#8): testdata/contract-keys, pinned by commit and checksum. The LF oracle runs on it, the codegen compile test builds bindings from it and an exercise-by-key command from them, and the commands__contract_keys conformance row generates from it.
  • v2_allocate runs the receiver’s side of a leg (CANTON_TOKEN_SIDE=receiver), so a V2 settlement between three different parties runs from the examples.
  • The live suites run nightly and on demand against a disposable LocalNet with OIDC (#9, contributed by the C#/.NET SDK’s maintainer).

Fixed

  • canton-lf 0.3.x does not build on a fresh install. zip 2.6, inside the 2 range the crate accepts, added a type parameter to ZipFile, which canton-lf named; the workspace lockfile held zip at 2.4, so CI stayed green while cargo add canton-lf, cargo add canton-codegen and cargo install canton-codegen-cli (without --locked) failed to compile. The entry is now read through std::io::Read, which builds on every 2.x, the lockfile moves to zip 2.6.1, and a new CI job builds the workspace with every dependency at its newest compatible version.
  • The lockfile, which canton-codegen-cli installs with under --locked, moves past the RustSec advisories published since 0.3.1: rustls 0.23.46, rustls-webpki 0.103.16, webpki-roots 1.0.11, tokio 1.54.1, ctutils 0.4.5. Three advisories against zip (RUSTSEC-2026-0367, -0368, -0369) are fixed only in zip 9 and concern its extraction API, which canton-lf does not call: it reads each DAR entry into memory under a size cap. Moving to zip 9 changes the error type canton_lf::DarError::Zip carries, so it waits for 0.4; deny.toml records the three with that reason.
  • The Prepared and Prepare::with_hashing_scheme_version docs still said the SDK did not recompute the hash and that the scheme defaulted to the participant’s; both were stale since 0.3.1 (#10).

[0.3.1] — 2026-09-26

Fixes from an internal security review of 0.3.0, run ahead of the independent audit. No new features; every change closes a way for a hostile participant, registry, token endpoint or DAR to make the SDK do something its caller did not ask for. Additive to the public API; behaviour changes are listed.

Fixed — signing and onboarding

  • canton-ledger: the hash of a prepared transaction is recomputed, not taken from the participant. Under hashing scheme V2 (the default, and now requested explicitly) the new canton_ledger::hashing module rebuilds the hash from the returned transaction and metadata, byte for byte as Canton does (checked against Canton’s own test vectors), and prepare_submission refuses a response whose hash is of something else. A response under an unknown scheme, a scheme other than the one requested, or with a hash that is not 32 bytes is refused too. A caller that asks for another scheme explicitly signs the participant’s hash as returned, with a warning.
  • canton-admin: the external-party onboarding set is read before it is signed for. The multi-hash is recomputed over the returned topology transactions, each transaction is decoded, and only the key’s own namespace delegation, a key mapping to that key, and hosting with confirmation or observation rights are accepted; a participant that slips in a second host with submission rights, a delegation to another key, or any other mapping is refused. allocate_external_party refuses a participant that allocated a party other than the one signed for.

Fixed — transport and retries

  • canton-auth, canton-ledger, canton-token: HTTP redirects are no longer followed. The token client refuses a 3xx from the token endpoint instead of replaying the client secret at the redirect target; the JSON Ledger API client and the registry client answer a 3xx as the status it is, unretried, so a bearer token or a command body is never re-sent elsewhere.
  • HTTP response bodies are read only up to a bound on all three lanes: the JSON client up to its decoding limit (with_max_decoding_message_size), the token client up to 64 KiB, the registry client up to 64 MiB; error bodies are kept up to 64 KiB. The bound holds without a Content-Length.
  • canton-core: a server-recommended retry delay is capped at MAX_SERVER_DELAY (60 s, logged when capped), a spelled retryInfo is clamped after parsing, and the jitter arithmetic can no longer panic on a hostile value. A RetryInfo detail on its own no longer makes a permanent gRPC code or a permanent HTTP status retriable.
  • canton-ledger: a retried JSON submission counts as de-duplicated only on DUPLICATE_COMMAND or ALREADY_EXISTS, not on any HTTP 409 (which is ABORTED as often as not on Canton’s JSON lane) or on a body that merely mentions the word. Two backpressure refusals no longer come back as Ok.
  • canton-ledger, canton-admin: a party listing that cycles page tokens or never ends is reported as incomplete (MAX_LIST_PAGES) instead of followed until memory runs out.

Fixed — codegen

  • canton-codegen: a Daml name cannot inject into generated docs. Lowering refuses a choice, field or package name carrying a control character or */, and the emitter keeps every doc attribute on one line, so a DAR cannot plant a Markdown fence (and with it a doctest that cargo test would run) in the generated crate.

Added — Daml-LF 2.3

  • canton-lf decodes LF 2.3, the version Canton 3.5.x SDKs emit and a DevNet participant already vets (Digital Asset’s utility apps, daml-stdlib 3.5.1). The vendored archive schema is now Canton 3.5.17’s, trimmed as before (expression bodies stay opaque), and the conformance oracle runs against the 3.5.17 JVM reader (daml-lf-archive_2.13). Bindings generated from a 290-package DevNet DAR at LF 2.3 compile; the committed bindings are unchanged. In the generated types pb::daml_lf_dev is now pb::daml_lf and a Type referencing an interned type spells it InternedType, following the schema. canton_lf::pb is now #[doc(hidden)]: generated schema types, exempt from SemVer as canton-proto’s are (ADR-0002), reachable but not part of the documented surface; CI’s semver guard skips canton-lf for this one release and takes it back with 0.3.1 as the baseline.
  • fetch_packages skips a package in an LF version the build does not read and continues, instead of stopping at it.

Changed

  • Prepare asks for HASHING_SCHEME_VERSION_V2 explicitly rather than leaving the choice to the participant.
  • canton-proto’s smoke_version example speaks TLS to an https:// participant, so it runs against a DevNet validator as well as a LocalNet.
  • Two live-suite assumptions that held only on a LocalNet are gone: the participant id is checked for its shape rather than a participant:: prefix, and the PQS suite expects a ledger effective time only from a store that read the ledger from the beginning (SCRIBE_PIPELINE_LEDGER_START is the hint).
  • tools/pqs/wait-ready.sh waits for Scribe’s ingestion to settle, not only for its schema to exist.
  • docs/security/audit-scope.md names 0.3.1 as the commit under review and states the interactive-submission guarantee as the code now provides it.

[0.3.0] — 2026-09-23

Changed — one crate per Daml package (breaking)

  • The eight token-standard and featured-app packages are now crates of their own: canton-splice-api-token-metadata-v1, -holding-v1, -allocation-v1, -allocation-instruction-v1, -allocation-request-v1, -burn-mint-v1, -transfer-instruction-v1 and canton-splice-api-featured-app-v1. canton-splice-amulet, -wallet, -wallet-payments and canton-quickstart-licensing reference them instead of carrying copies.
  • Why it is breaking: those packages previously existed once inside each crate that depended on them, and Rust treats the copies as unrelated types. A ContractId<Holding> read through canton-splice-amulet did not typecheck against the Holding of canton-splice-wallet, so no program could use both crates together. They are now one type.
  • Migration: a path that went through the containing crate now goes through the package’s own crate — canton_splice_amulet::splice_api_token_holding_v1::… becomes canton_splice_api_token_holding_v1::splice_api_token_holding_v1::…, and the new crate is added to Cargo.toml. Nothing changes on the ledger: the package ids, template ids and wire encodings are identical.
  • splice-util is unchanged — it ships no DAR of its own, so it stays inside canton-splice-amulet, and canton-splice-wallet reaches it from there.
  • canton-daml-stdlib (new) owns the Daml standard library — daml-stdlib, daml-prim, ghc-stdlib and their per-module packages — and every bindings crate references it. The same defect applied to those: RelTime appears in public field types (HoldingView’s lock expiry, AnyValue::AV_RelTime), and each crate declared its own. The standard library ships no DAR, so this crate is generated from a selection of packages out of the DAR committed to this repository — which means its drift guard needs no external checkout.
  • Every crate shrank by the standard library it no longer carries: canton-splice-api-token-metadata-v1 134 KB → 12 KB, canton-splice-wallet 310 KB → 189 KB, canton-splice-amulet 556 KB → 435 KB.

Fixed — the pre-submission review, September 2026

Five agents went over the branch the way M1 and M2 were reviewed — a gap audit against the proposal, three adversarial code reviews (token, signing, PQS + conformance + release hygiene), and a test runner over every live suite, both feature profiles, the CI gates and a semver diff — and this is what they turned up. Every finding was re-verified against the code before it was acted on; the ones that did not survive that check are not here.

  • canton-pqs did not require TLS from connect_tls. The connection string went to the driver unchanged, and tokio-postgres’s default is sslmode=prefer — so a store with TLS off, or a string carrying sslmode=disable, gave a plaintext session from a method named connect_tls, with no error. The mode is Require now, and a test proves a store that answers the TLS request with “no” is refused.
  • canton-pqs called every error without a SQLSTATE retriable — a typo in the connection string, a wrong password, a parameter the driver cannot bind, a column it cannot decode. An application looping on is_retriable() spun on its own mistake. The driver’s permanent kinds are classified InvalidRequest; and the client, which held one connection and could only ever say “retriable” once the store had closed it, now reconnects.
  • canton-pqs had no deadlines. Connect and query are bounded (30s by default; connect_with_timeout, with_timeout) and surface as Error::Timeout.
  • Predicate::eq("count", 5) matched nothing. LF-JSON stores every number as a string; a jsonb number never equals one. Numbers bind in their string form now. And a limit without an order was a random sample — pages could overlap — so a limit orders by ledger offset, then contract id.
  • The registry’s 409 was permanent. Every token-standard document defines it as “a contract in the reply is mid-reassignment”: transient, gone in seconds. Reported as a connection-class error now, which retries.
  • Instrument was a transcription of metadata-v1 1.0.0. The 1.2.0 document adds paused, pauseInfo and accountInputFieldsToShow; a wallet on this crate could not say why a paused instrument refused. Added. The seven OpenAPI documents are vendored under testdata/openapi/ at the Splice release the LocalNet runs, with provenance, and a test pins every path the crate spells against them.
  • canton_token::holdings. A transfer or allocation naming no input holdings is refused by Splice’s registry, and one naming a locked holding fails at the interpreter — both after the registry round-trip succeeded, which is how the V2 examples failed on a machine that had just run the allocation example. The module reads the sender’s Holding views from the ACS and says which are spendable; the examples use it when CANTON_TOKEN_HOLDINGS is unset, and all three commit first try.
  • execute_submission did not recognise its own retry. A retried attempt the participant de-duplicated came back as ALREADY_EXISTS and was reported as failure — the case submit_commands already handles. Handled the same way; Prepared::change_id / Executable::change_id hand out what await_completion needs; execute_submission_and_wait returns the whole response, completion offset included, rather than the update id alone.
  • Three live suites skipped silently. canton-token, canton-pqs and interactive_live neither said so nor looked at CANTON_TEST_REQUIRE_LIVE; a run with no registry, no store and no participant was green. They carry the same guard as the ledger and admin suites.
  • A conformance row claimed party management over JSON with a test that built a gRPC client. There is no /v2/parties in the JSON client; the row is a declared gap with the reason. (packages__json_package_mgmt went the other way — see reading packages.)
  • docs.rs metadata was missing on every M3 crate, so connect_tls and the in-memory key would not have been documented, and nothing after publication can fix that. Every hand-written crate carries the block now, with doc(cfg) on the gated items. Generated crates forbid unsafe_code in the file itself — the workspace lint never reached them.
  • Docs that had fallen behind: RELEASING.md’s thirteen-crate order (there are thirty-one; derived from the manifests now), the facade’s feature list (ed25519 and pqs-tls were undocumented), canton-token’s CIP-56-only title for a crate that implements both standards, and this file, which the merge from 0.2.3 had left with every M3 entry under a published version.
  • Coverage and mutation passes over the M3 crates, the two phases the September runner did not reach. Every canton-token workflow function — V1 allocation, the instruction choices, all of v2::* — now has an in-process drive against the registry stub that asserts the request and that the registry’s context lands inside the exercise argument (a mutation run showed a workflow could drop the context and no test would notice); the holdings read is driven against an in-process StateService; the JSON transport’s bounded reads, wrapped submission and party endpoints are covered by the scripted server; the interactive-submission accessors, the registry’s transport verdict (refactored to be testable on the strings it reads), the PQS row accessors and path spellings, and the signer’s Debug and fingerprint are pinned by unit tests. The proposal’s “submit → observe → query on both transports” runs in CI as one flow per transport against an in-process participant (submit_observe_query_over_grpc / _over_json), with the contract id threaded through all three steps; the live suites run the same flow against a real node.
  • rustls 0.23.45 in the lockfile for RUSTSEC-2026-0285 (TLS 1.3 handshake messages accepted across encryption-level boundaries), which cargo-deny caught on the milestone PR. The manifests already allowed the fixed version; a consumer of 0.2.x gets it with cargo update -p rustls.
  • The live suites run on any LocalNet, not only cn-quickstart. The interactive-submission suite runs against an unauthenticated participant (Canton Builder Tool, Splice LocalNet’s default) with nothing beyond the endpoint, where before it skipped for want of an admin client; the PQS suite reads Amulet — which any Splice LocalNet holds once its party has tapped — instead of the reference app’s AppInstallRequest, and tools/pqs/compose.yaml runs a Scribe store against any participant on the host in one command. The README’s testing section says where a LocalNet’s registry is (scan.localhost:4000) and how a single party runs every token example, settle_batch included.
  • The live runs are on record. The three token-standard examples and the JSON-only package read were run again on 2026-09-07 and their output is committed verbatim, with the environment and commands, under docs/verification/; the compatibility matrix’s offsets now point at that run. On 2026-09-22 the same examples ran against a validator on the Canton Network DevNet (Canton 3.5.17, Splice 0.8.1, the public SV Scan as the registry): both transfers settled as direct, the allocation was created and then withdrawn by the sender — v2_withdraw_allocation is the new example that does so — a second allocation was settled by its executor through Amulet’s settlement factory (v2_settle, the other new example, which finds the allocations naming the caller as executor and settles each settlement as one batch), and every update id was read back from the node. The factory first refused a one-sided leg: a V2 leg needs both sides authorised, so v2_allocate now authorises both when the sender is also the receiver. The token examples take a CANTON_TOKEN bearer token as well as OIDC credentials, and an OIDC CANTON_TEST_AUDIENCE.
  • canton-ledger’s tests did not build on their own: the dev-dependency on canton-signer inherited default-features = false and the tests import the in-memory key. Green only through the facade’s feature unification.

Changed — Error::Http grew a url field (breaking)

canton-core reads Error::Http::body as the response — a category, a retry delay, a correlation id — and the registry client had been prefixing the URL onto it, which broke every one of those reads on that lane. It was doing so for a real reason: a 404 from a mistyped base URL and a 404 from an unknown contract read the same otherwise. Both were right, so the variant carries both: body is the response and nothing else, url: Option<String> is where the request went, and Display reads http 404 at <url>: <body>.

The variant is #[non_exhaustive] from here. Construct it with Error::http(status, body) or Error::http_at(status, body, url); match it with ... That is the migration for code on 0.2.x that built or matched the variant by hand.

Added — interactive submission with a pluggable signer

  • canton-signer (new) — Signer, an object-safe async trait for signing a prepared transaction’s hash, so an HSM or KMS fits behind it. Ed25519Key is the in-memory implementation, on ring, behind a default ed25519 feature an HSM implementer can turn off.
  • A key is not yet an identity: Canton addresses a key by a fingerprint it computes itself, so Ed25519Key cannot sign for the ledger until into_signer(fingerprint) gives it one. The ordering the types enforce is the real one.
  • canton-ledger — prepare_submission, execute_submission, execute_submission_and_wait, execute_submission_and_wait_for_transaction, with the flow as a type per stage: Prepare → Prepared → Executable. Nothing unsigned can be executed, because there is no such value. Signing refuses a party that is not acting, and unsigned_parties answers “who is still missing” without a round trip.
  • canton-ledger — connected_synchronizers, so “which synchronizer?” is a question the participant answers rather than configuration.
  • canton-admin — generate_external_party_topology and allocate_external_party: onboarding a party whose key the participant does not hold, which is two calls with a signature over the onboarding multi-hash in between.
  • Verified live on a Canton 3.5.7 participant: an external party is onboarded by signing its own topology, a command is prepared, signed off the participant and committed, and a signature from the wrong key is refused — Received 0 valid signatures from distinct keys (1 invalid). That last one is the control: it is what shows the signature is carrying the authorization.

Added — token standard (CIP-56)

  • The canton facade re-exports the three new crates as canton::signer, canton::token and canton::pqs. Without that, cargo add canton — which the README describes as getting “everything below as one version-locked set” — delivered none of this milestone.
  • canton-token (new) — the workflow over the generated token-standard types, which it does not re-declare: RegistryClient for the off-ledger API, choice contexts with their disclosures, TransferFactory_Transfer, and the allocate path with execute / withdraw / cancel.
  • Every path and payload comes from the standard’s OpenAPI documents. Two are vendored in cn-quickstart; the other two were taken from the pinned upstream commit those copies name.
  • The standard specifies choiceArguments as the choice “encoded using the Daml JSON API, with extraArgs.context and extraArgs.meta set to the empty object”, and returns the context the same way — which is what canton-daml implements. So a generated choice serializes straight onto the wire and the reply deserializes straight into the generated ChoiceContext.
  • TokenCommand keeps a command and its disclosures together and converts into either an ordinary or an interactive submission, so a token transfer can be signed by a party whose key the participant does not hold.
  • Each choice on an allocation fetches its own context: the standard says a context may be specific to the choice, so sharing one is a bug that works until a registry starts distinguishing them.
  • The V2 workflow lives in canton_token::v2, keeping the same function names one module down: token::transfer and token::v2::transfer are different standards, and the path is what says which you meant.

Added — conformance to the Ledger Client Standard

  • canton-conformance (new, not published) — one test per capability of the standard, named for the row it answers, so a reviewer can read the two side by side. conformance/capabilities.toml is the machine-readable checklist, derived from the capability matrix DA published.
  • A completeness guard asserts the two agree in both directions: no capability claimed without a test, and no test claiming a capability the registry does not list. Checked against the suite’s source rather than a run of it, so an ignored test does not count as coverage.
  • The suite exercises the SDK through the canton facade, which is how it found that canton::telemetry was not re-exported — the metric names and transport labels an application builds a dashboard from were unreachable from cargo add canton. They are exported now.
  • One row is honest about its limits rather than quietly weaker: contract keys are generated and exercisable by key, but no template in this corpus declares a key, so the test asserts the mechanism is present and says so.
  • docs/compatibility-matrix.md — toolchain, platform, Canton release, Daml-LF minors, token-standard version, and a table of what CI checks against what needs a live node.
  • A conformance CI job runs the suite and asserts the count matches the registry exactly, with nothing ignored. >= across every binary in the package left slack equal to the number of guard tests, and an #[ignore]d capability could hide in it — the name-based guard reads the suite’s source, so it counts an ignored test as coverage.

Fixed — a full M3 review, and what it turned up

Two independent reviews of the whole milestone: one against the proposal, one hunting bugs and checking whether the problem classes found in M1 and M2 had recurred. Every finding below was verified against the code before being acted on, and two of the reviews’ own claims did not survive that check.

Retriability, which M1 got wrong in two places and M3 got wrong in two more.

  • canton-pqs classified every Postgres SQLSTATE as InvalidRequest, which is not retriable. A failover (57P01), a serialization failure (40001), a deadlock, 53300 too_many_connections — all reported to the caller as “PQS rejected the query”, pointing whoever read it at a predicate that was never wrong, and making an application give up on a five-second restart. Now classified by SQLSTATE class: 08, 40, 53, 55 and 57 are transient, everything else is the caller’s. classify had no test at all; it has three.
  • canton-token’s registry client turned every transport failure into a retriable Error::Connection carrying only reqwest’s outer sentence. A certificate the client cannot verify was retried forever, and the word “certificate” — which lives in the source chain — never reached the operator. The chain is now walked, and a certificate failure, a malformed URL and a redirect loop are reported as non-retriable.

Two integrity checks that did not check.

  • canton-admin::get_package compared the participant’s own hash field against the id that was asked for, and skipped the comparison entirely when that field was empty — while its doc claimed “a package id is the hash of its payload, so that check is what makes asking by id pin the content”. It now hashes the payload.
  • The committed V2 payload corpus is documented as needing no checksum file “because each file name ends with the id it hashes to”. Nothing hashed them. The shared generation table now does, and rejects a name that does not carry a 64-hex id — rsplit('-').next() returned the whole filename when there was no -, so foo.lfpayload yielded the package id foo and the error written for that shape was unreachable.

A guard that was absent from the configuration it was written for. The Ed25519 signature-length check in canton-signer sat inside #[cfg(feature = "ed25519")], and the crate’s docs tell HSM/KMS callers to build with default-features = false. The length of an Ed25519 signature is a property of the algorithm, not of the in-memory implementation, so the check is now unconditional — and CI runs the tests with default features off, which the feature-powerset job could not (it passes --no-dev-deps, so tests are never compiled under a feature combination).

A query that silently returned less than it should. Predicate::not_eq rendered as <>, which yields SQL NULL against a missing JSON path and drops the row. Since a query matches on the package name so it survives a Smart Contract Upgrade, a result set normally mixes payloads of different versions with different field sets — so adding a field in v2 would have quietly dropped every v1 contract from a not_eq on it. Now IS DISTINCT FROM. The ordered comparisons keep NULL semantics, which is right, and now say so.

instrument() reported a misconfigured base URL as an answer. Any 404 became Ok(None) — “this registry does not issue it” — so a base URL one path component off made a wallet’s polling call return a steady, quiet None while every other call on the same client failed loudly. A 404 whose body is not JSON did not come from a registry handler and is now an error that says so.

The conformance registry claimed less than the SDK does, and nothing could tell. conformance/capabilities.toml listed 39 rows; the Ledger Client Standard has 49 in scope. The two existing guards compare the registry to the suite, so a row missing from both was invisible to them — and ten were, including Explicit disclosure, an M3 row and a named sub-item of the token-standard deliverable. Meanwhile the CI job derived its threshold from that same short file, so the gate grew easier as the registry shrank.

  • The ten rows are added, each with a test: explicit disclosure, interface subscriptions, node health, gRPC package management, vetting, the three topology reads, and user self-inspect.
  • One of them is not added as a claim. “JSON package mgmt” is an M1 row this SDK does not implement — JsonClient covers version, ledger end, commands, ACS and updates, and has no package endpoint — so it is recorded as a [[gap]] with a reason. A capability nobody can name a test for is not one this SDK has, and writing a test that named it anyway is precisely the fake coverage the mechanism exists to prevent.
  • A third guard asserts capabilities + gaps account for every in-scope row, so dropping one now fails rather than passing quietly. Removing “Explicit disclosure” was used to check the guard actually fires.
  • The CI counter requires an exact match with zero ignored, and counts [[capability]] blocks rather than id lines — gaps carry an id too.

Reachability through the facade. canton-pqs’s tls feature was not forwarded, so PqsClient::connect_tls did not exist for a cargo add canton user; reaching a PQS behind TLS meant a second, separately-versioned dependency — the version skew the facade exists to prevent. Forwarded as pqs-tls, and the conformance suite now names the method, which is the file that states the rule.

The registry stub did not check the HTTP verb. It recorded path and body only, and answered 200 to any request line, so a factory issued as a GET passed every assertion — reqwest sends the body either way. The method is recorded and asserted.

Documentation that claimed more than the code did. The compatibility matrix said bindings drift covers “all nineteen” generated crates (CI covers eighteen; canton-quickstart-licensing builds its DAR from source and is guarded locally), and opened by saying every row is exercised in CI while its own table lists four that are not. The capability registry cited canton-kernel and canton-daml-runtime, neither of which exists — they are canton-core and canton-daml. The facade’s landing-page table omitted all three M3 crates and still described the token crate as CIP-56 only. Three intra-doc links were broken, two of them predating this work.

Reviewer claims that did not survive checking, recorded because a review is evidence, not a verdict: the count of missing capability rows was first reported against a 49-row total I could not reproduce until I found that three sections of the map use a different column order; and “all ten are implemented” was wrong for JSON package management, which is why that one became a gap rather than a test.

Verified — both token standards, against a live registry

The registry half of canton-token had been exercised only by tests/inprocess.rs, because the environment was recorded as having no registry. That was wrong: a LocalNet’s registry is the scan, which the super-validator runs, and cn-quickstart runs one under SV_PROFILE=on on port 5012 — it simply does not publish that port to the host.

Against it, with the Amulet instrument declaring both standards (splice-api-token-transfer-instruction-v1 and -v2):

  • A V1 transfer settles end to end — factory resolved against the registry, submitted with the four contracts it named for disclosure, committed at offset 39643.
  • A V2 Account-based transfer against Amulet on a LocalNet, exercised as a V2 implementation — committed at offset 39646. The proposal’s verification clause names the V2 reference token; that network is retired (see the compatibility matrix), so this is the closest available target, and the first time the /v2/ paths, the Account model and the actors field have met a real registry rather than a transcription of its specification.
  • V2 event parsing on a committed transaction — events::holdings_changes read back one holdings change: one holding spent, two produced, two transfer legs. The interface is matched on its qualified name rather than its package id, and this is the first evidence that a real registry’s event satisfies that match.
  • tests/live.rs (new, env-gated on CANTON_TOKEN_REGISTRY_URL) covers the registry read path: the admin party parses, instruments decode with the optional fields genuinely absent, a not-issued instrument is None rather than an error, page tokens round-trip, and the declared API versions are readable. As with the other live suites, a set variable that cannot be reached fails rather than skips.

What the first real submission found. Both examples passed an empty inputHoldingCids, with a comment that a registry may select holdings itself. Splice’s reference registry does not: the transfer reached the Daml interpreter and failed with At least one holding must be provided. An end-to-end example that cannot complete against the reference implementation is not end to end, so both examples now take CANTON_TOKEN_HOLDINGS and say why it is not optional in practice. The V2 example also reads its own committed transaction back through holdings_changes, which is where a reader would look for it.

Fixed — a three-milestone review, and the fixes that had siblings

Three reviews: M1+M2 against their proposal text, M3 exhaustively, and a sweep for fixes applied in one place while the same pattern survived elsewhere. The third was the one worth running.

The cause chain, now in one place. The previous round fixed how the registry client reports a transport failure. Four other sites had the same two defects — a message that dropped everything under the outer error, and a verdict of retriable Error::Connection for conditions no amount of waiting fixes. Two of them, canton-ledger’s JSON client and canton-auth’s token fetch, sit inside run_with_retry, which the registry client did not: an IdP or participant behind a certificate the client cannot verify ran the full retry schedule and then reported error sending request for url (…), with the word certificate one level down in a chain nobody read. canton_core::chain is now the single walker — three crates had each grown a private copy before anyone noticed the fourth had none — and each site classifies for itself, because what is permanent differs: a bad token endpoint is Auth, a bad JSON body is UnexpectedResponse.

PqsClient::connect had the bug the same file’s classify had just lost. Fixed by the SQLSTATE class rule already written twelve lines below it. Verified against the running store: a wrong password is now invalid request: … the store refused the connection (28P01): … FATAL: password authentication failed, non-retriable, instead of a retriable db error.

The token deliverable could not be called through the facade. Every entry point takes a generated type — transfer takes a Transfer — and the facade depends on no canton-splice-* crate, so a cargo add canton user could reach the functions and not name their arguments. canton_token::types re-exports the modules (types::v1::transfer_instruction, types::v2::holding, types::metadata …), whole modules rather than a hand-listed set that goes stale on the next record the standard adds. This is the pqs-tls defect one level up, and larger: that one hid a feature, this one hid the milestone.

External-party onboarding now checks the one relation it can. A party id is <hint>::<namespace>, and for an external party the namespace is the fingerprint of the key being registered — so a response assembled for a different key is now refused before anything signs it. The multi-hash still cannot be recomputed (that needs Canton’s hash-purpose scheme, which this workspace does not implement); this is the free check that was missing beside it.

Half-applied fixes from the previous round, completed:

  • The HTTP-verb assertion reached the six POST endpoints and none of the GET ones — in the file whose own new comment names both.
  • CANTON_TOKEN_HOLDINGS was added to both examples with a doc that still carried the pre-fix reassurance, contradicting the call-site comment eight lines below it. The helper now warns when the variable is unset rather than defaulting quietly into a failure that arrives from the Daml interpreter.
  • pqs-tls reached the manifest and the module table but not the facade’s own canonical feature list, which reads as complete.
  • get_package’s doc still described the check the hash fix removed — that the SDK trusts the server’s hash field.

Documentation that claimed more than the code did. The published matrix called a run “against the V2 reference token” six lines after naming the instrument as Amulet; it now says what it is — a V2 implementation exercised as one — and points at what the clause still needs. docs/daml-lf-type-mapping.md, the M2 deliverable document, said references are “always” crate::<package>::<module>::<Type>; every one of the nineteen generated crates in this repository disproves it, and the cross-crate form is now documented with the emitted code beside it. ADR-0005 claimed the bindings crates encode the DAR version in their version metadata (they do not). The CHANGELOG said thirteen generated crates in the same entry where it said nineteen. Two CI comments described a four-guard shape that no longer exists, and canton-quickstart-licensing pointed at the wrong crate for its own drift guard.

The CI step gating the conformance count did not check that it ran. cargo test exits 0 on “0 passed”, so deleting every completeness guard would have turned green the step that everything downstream depends on. Its sibling steps already assert their own markers.

Added — the V2 token standard (CIP-0112)

  • Six new bindings crates: canton-splice-api-token-holding-v2 (which carries the Account model), -transfer-instruction-v2, -transfer-events-v2 (the EventLog a V2 transfer is parsed from), -allocation-v2 (with SettlementFactory, Allocation_Settle and FinalizedAllocation — executor settlement), -allocation-instruction-v2 and -allocation-request-v2.
  • V2 reuses splice-api-token-metadata-v1: there is no metadata-v2.
  • Where the packages came from. V2 ships as no DAR anyone publishes — cn-quickstart carries only the V1 set, the Splice repository holds Daml sources rather than built artefacts, and there are no release assets. It is live on the network, so it was taken from a participant through the Ledger API’s GetPackage and committed under testdata/token-standard-v2. A package id is the SHA-256 of its own bytes and each file name ends with that id, so the corpus is pinned by construction and needs no checksum file.
  • The V2 crates reference canton-daml-stdlib and the metadata crate rather than copying them, exactly as the V1 crates do — the ghc-stdlib and daml-stdlib packages they depend on carry the same ids the V1 corpus uses, which is why one stdlib crate serves both.
  • The drift guard covers all nineteen generated crates, and the seven generated from committed packages are guarded in CI with no external checkout.
  • canton_token::v2 — the workflow over those types: transfer with accept/reject/withdraw, allocate, settle_batch, and the allocation and allocation-instruction choices. Four differences from V1 a caller meets at once, each of which the module documents: a transfer moves between accounts rather than parties (and both the owner and the provider of an account are optional); every choice names its actors, which V1 left implicit in the submitting party; a V2 allocation names the settlement it belongs to when it is created; and settlement moved to a batch on the settlement factory, which is what lets both legs of a delivery-versus-payment settle together. Asking for a V1 execute-transfer context is refused before it reaches the network, naming the settlement factory as its replacement — a registry would answer 404 with nothing that says why.
  • The V2 paths were taken from the OpenAPI documents rather than derived from V1: the collections are singular (transfer-instruction, allocation-instruction, allocation) except the choice contexts on an allocation, which are under the plural allocations. A test pins each one, because a client that regularises the odd one out gets a 404 from a registry that is working correctly.
  • canton_token::v2::events — holdings_changes reads what actually moved off a transaction. A V2 registry records it by exercising EventLog_HoldingsChange on the EventLog interface, and that is the only complete answer: the creates and archives show holdings appearing and disappearing without saying which transfer they belonged to. Events are matched on the interface’s qualified name, not its package id — pinning the id would make a client stop seeing events the day a network upgraded the standard, and that failure reads as “nothing moved” rather than as an error. The choice name alone is not enough either: it is not reserved. An argument that does not decode is reported rather than skipped, for the same reason.
  • examples/v2_transfer.rs is the V1 example’s counterpart, written to be read beside it, and examples/v2_allocate.rs is the allocation flow — the half that needs three parties rather than two. Run against the live registry with app-provider as sender, app-user as receiver and the super-validator as executor: allocated at offset 40176, six events. It stops before settling on purpose; that is the executor’s move from their own participant, and one process holding both sides would prove nothing about a pattern whose whole point is that the principals differ.
  • The allocation example is also the first thing written entirely against canton_token::types — it names SettlementInfo, AllocationSpecification, TransferLegSide and Account with no direct dependency on any canton-splice-* crate, which is what the re-export was for.

Added — reading packages from a participant

  • The same reads over JSON. JsonClient::list_packages (GET /v2/packages) and JsonClient::package_status (GET /v2/packages/{package-id}/status), returning the gRPC path’s PackageStatus so a caller switches transports without re-learning the vocabulary. Asked for in issue #2 by a JSON-only deployment; the conformance registry’s one declared gap (packages__json_package_mgmt) becomes a capability with it. DAR upload (POST /v2/dars) is deliberately not included — an operator write with its own authorization story, tracked separately.

  • Party management over JSON. JsonClient::list_known_parties_page / list_known_parties (GET /v2/parties, paged), get_parties (GET /v2/parties/{party}), allocate_party / allocate_party_with (POST /v2/parties, the latter with synchronizer, user, identity provider and annotations through AllocateParty), update_party_details (PATCH /v2/parties/{party} with a field mask) and participant_id. All answer in the gRPC path’s admin::PartyDetails, so a caller switches transports without re-learning the type. Allocation and update are not retried, for the reason canton-admin gives. With this the conformance registry’s last declared gap (parties__json_party_mgmt) is a capability: 51 rows, 51 tests, no gaps (two rows the plan had filed under post-v1 turned out delivered by the signing work — external-party creation and listing connected synchronizers — and are claimed).

  • canton-admin — get_package downloads a package’s ArchivePayload bytes and checks the hash the participant returns against the id that was asked for. With list_packages, that is enough to generate bindings from what a network has actually vetted rather than from a file that has to be found — and for a package that exists only on a network, there may be no file.

  • canton-lf — decode_payload reads that shape. A DAR entry is a whole Archive (payload, hash and hash function together) and decode_package reads that; the Ledger API returns the three as separate fields. Mistaking one for the other fails with a protobuf error about a hash field that says nothing about the cause.

  • canton-codegen — lower_packages_selecting generates from decoded packages rather than only from a DAR.

Added — the Participant Query Store

  • canton-pqs (new) — a typed read client for PQS/Scribe. A query names its template by type: the qname is PACKAGE_NAME:MODULE_NAME:ENTITY_NAME, read off the generated Contract, and it is the package name, so a query survives an upgrade instead of pinning one build.
  • PQS stores payloads in the Daml JSON encoding, which canton-daml implements, so a contract read from Postgres deserializes into the same generated type a transaction stream yields. Two ways in, one set of types.
  • Predicates compile to parameterized statements. Every value is a parameter and so is every JSON field path — bound as text[] and applied with #> — so the statement text depends on a query’s shape and never on its data.
  • Ordered comparisons on numbers are numeric: LF-JSON carries Int64 and Numeric as strings, so comparing lexically would sort "9" after "10".
  • tls feature for connect_tls; off by default, since PQS is usually inside a trust boundary.
  • Verified live against a running Scribe 3.5.4 store: 969 active contracts read as typed payloads, payload and party-column predicates filtering in the database, containment, lookup by id, and a pinned-offset read.

Added — codegen

  • Selection and lower_dar_selecting: generate a crate from part of a DAR. A reference that leaves the selection is reported as a skipped type rather than emitted, since the path would name a module the crate does not have — it would compile where it is generated and fail in the consumer.
  • Selection::and_prefixed and ExternalPackages::with_prefixed take package name prefixes, for a family that arrives as many packages. The standard library is some thirty of them, and which ones a DAR carries depends on what its Daml source touched, so an exact list is right for one DAR and wrong for the next. A prefix matches at a - boundary: daml-prim does not also match a daml-primary.

Publish order — this replaces 0.2.0’s, which no longer works. canton-ledger and canton-admin now depend on canton-signer, so it has to go out before them rather than not at all:

canton-proto → canton-core → canton-auth → canton-lf → canton-daml → canton-signer → canton-ledger → canton-admin → canton-codegen → canton-codegen-cli → canton-pqs → canton-daml-stdlib (every binding references it) → canton-splice-api-token-metadata-v1, canton-splice-api-featured-app-v1 → -holding-v1 → -allocation-v1 → -allocation-instruction-v1, -allocation-request-v1, -burn-mint-v1, -transfer-instruction-v1 → canton-splice-api-token-holding-v2 → -transfer-instruction-v2, -transfer-events-v2, -allocation-v2 → -allocation-instruction-v2, -allocation-request-v2 → canton-splice-amulet → canton-splice-wallet-payments → canton-splice-wallet → canton-token → canton (last: the facade re-exports everything above).

canton-sample and canton-quickstart-licensing stay unpublished.

Changed — CI

  • The packaging check derives its crate list from cargo metadata rather than a hand-written one, so a crate added to the workspace cannot be left out of it — the previous list silently stopped covering the eight crates above.
  • The bindings drift guard checks all nineteen generated crates (it checked three), reading the crate/DAR/external-package table from the same file the regeneration example uses so the two cannot disagree. canton-daml-stdlib is generated from the DAR committed here, so that one is guarded in CI with no checkout at all.

[0.2.3] — 2026-08-25

Fixed

  • A gRPC stream or submit did not retry a connection that died in flight. tonic surfaces a dropped connection mid-call as a Status with code Unknown (message “transport error” / “h2 protocol error”), not as a transport::Error — that variant is only for connection establishment. So Error::is_retriable() treated the most ordinary failure there is as terminal: updates_resumable gave up on the first blip and run_with_retry never fired. Data integrity was never at risk (no loss, no duplicate — the failure surfaced as an error), but the resilience did not engage. A category-less Unknown/Internal status whose source chain is populated is now retriable; a bare server Unknown (decoded from trailers, no source) stays terminal. Found by killing a real TCP connection under load through a chaos proxy — in-process mocks had emitted Status::unavailable, which is why it never showed. The WebSocket resumable lane was already correct (it treats a socket close as a reconnect trigger, not via this predicate).

  • Template::from_json_created_event rejected the events-by-contract-id response shape. That endpoint wraps the created event as {"created": {"createdEvent": …}}; the helper peeled only the inner key and errored with “carries no createArgument”. It now unwraps both layers, so all four JSON shapes the API uses decode.

  • A test broke cargo test without --all-features. futures_are_spawnable asserted JsonSubmission::recover is Send, but that method is ws-gated while the assertion was not — so the default-feature build failed to compile. Invisible because CI ran tests only with --all-features; a second cargo test --workspace job (default features) now guards the whole class. Found by running the suite under every feature combination.

  • tokio-stream’s declared lower bound was 0.1, but the net feature it uses landed in 0.1.1 — a minimal-versions resolve does not build against 0.1.0. Corrected to 0.1.1. Found by cargo +nightly build -Z direct-minimal-versions.

[0.2.2] — 2026-08-24

Added

  • Conversion and hashing polish from a Rust API Guidelines audit (16 observations, none blocking; the audit’s two breaking items are decided rather than fixed — ADR-0010 defers the *ParseError renames and the removal of Party’s unvalidated From<&str>/From<String> to 1.0, the latter blocked from gaining a checked TryFrom today by std’s blanket impl).
    • ChangeId derives Hash — it is the key the docs tell applications to track submissions by, and it could not be a HashMap key. Store, TransactionShape and ClientAuth follow.
    • canton-ledger and canton-admin re-export Auth, ErrorInfo and ResourceInfo (and admin gains ErrorCategory): these appear in the signatures of Error::error_info/resource_info and Config::auth, so a user of one client crate could not previously name the types their own functions return without adding a canton-core dependency.
    • ErrorCategory implements TryFrom<i32> and From<ErrorCategory> for i32 alongside the inherent methods.
    • #[must_use] on the constructors that lacked it; docs.rs feature badges on all seven remaining ws-gated methods (two of nine had them).
  • A compile-time proof that every public future is Send (tests/futures_are_spawnable.rs) — an async API that cannot be tokio::spawned is unusable in a server, and the property breaks silently.
  • Hermetic coverage for the paths only live tests exercised, found by measuring coverage rather than reading it: the three TopologyClient list reads (36% file coverage, all three public methods untested without a node — the same shape of gap the M1 review named about live tests), both recovery handles’ recover, and Submission::submit_and_wait. Hand-written code measures 87.7% line coverage; cargo udeps reports no unused dependencies.

Fixed

  • The in-process duplicate-submission mocks answer synchronously on the fire-and-forget lane; a live Canton 3.5.7 participant does not — it accepts the RPC and reports the rejection on the completion stream. Established by adversarial probes run with the published 0.2.1 crates against a real node (which also confirmed the JSON duplicate answer is exactly the HTTP 409 + DUPLICATE_COMMAND the is_duplicate_submission predicate was written against, and that OIDC refresh survives a real token expiry). The mocks stay as pins of the synchronous case; their comments now say where reality differs.

[0.2.1] — 2026-08-24

Fixed

  • canton-ledger documented as failed on docs.rs. Two #[cfg_attr(docsrs, doc(cfg(feature = "ws")))] attributes went in without the crate declaring #![cfg_attr(docsrs, feature(doc_cfg))], which that attribute requires. cargo doc never sets docsrs, so the attributes compiled out and every gate stayed green; docs.rs does set it, on nightly, and the published crate’s documentation page said the build failed. The crates themselves installed and built correctly throughout — this only ever affected the rendered documentation, and it cannot be fixed for a version already published, hence this patch.
  • A docsrs CI job now builds the workspace the way docs.rs will, so the class of error that produced this cannot reach a release again.
  • The MSRV job never checked the MSRV. rust-toolchain.toml pins the channel for anything run inside the checkout and wins over the toolchain the CI action installs — rustup says so in its own log, then uses stable. So a job that asked for 1.88 ran on stable, and rust-version = "1.88" went unverified every time it was claimed. It happens to be true (1.88 checks clean), but nothing was establishing that. Both jobs that want a specific toolchain now set RUSTUP_TOOLCHAIN, which is the override that beats the file.
  • The canton facade declared all-features for docs.rs without the matching rustdoc-args, the same inconsistency one step behind.
  • cargo-semver-checks covered 5 crates of 11. The M2 crates had no published baseline to compare against until 0.2.0, and the job’s own comment said they should join afterwards — this is afterwards. canton-proto stays out by ADR-0002 and canton-codegen-cli has no library API.

Changed

  • RELEASING.md carries the post-publish verification the pre-publish gate cannot do: cargo package runs --no-verify because verifying needs the siblings to already be on crates.io, so the packaged artefact is first built by whoever installs it. The runbook now says to be that person first.
  • The README compatibility table listed 0.2 as unreleased.

[0.2.0] — 2026-08-24

All canton-* crates release in lockstep, so the M1 crates move to 0.2.0 with the rest. Everything the 0.1.x line gained after the M1 submission — the read request builders, the full Commands surface, Canton-native error classification (see 0.1.2 below) and the documentation fixes of 0.1.3/0.1.4 — is included.

Breaking, for code on 0.1.x

Two signatures change, both because the old one could not express a correct call. Everything else from 0.1.x compiles unchanged.

  • OidcConfig::auth0(domain, client_id, secret) → auth0(domain, audience, client_id, secret). Auth0 answers a client-credentials request without an audience by issuing a token for its own userinfo endpoint, which a participant rejects — so the old preset could not produce a working request. The audience identifies your Auth0 API and cannot be derived from the domain.

    - OidcConfig::auth0("my.eu.auth0.com", "client-id", "secret")
    + OidcConfig::auth0("my.eu.auth0.com", "https://ledger.example", "client-id", "secret")
    
  • CantonClient::await_completion(command_id, parties, offset, timeout) → await_completion(&ChangeId, offset, timeout). Canton identifies a command by (user, acting parties, command id), and matching on the command id alone can return another application’s completion. The parties move inside the change ID.

    - client.await_completion(&command_id, vec![party.clone()], offset, timeout)
    + client.await_completion(&ChangeId::new("", vec![party.clone()], &command_id), offset, timeout)
    

    Better still, take the identity from the submission rather than rebuilding it: let submission = client.submission(submit); then submission.recover(offset, timeout).

Added — type-safe codegen from DARs

  • canton-lf — native Rust Daml-LF reader: DAR container (zip + MANIFEST.MF) and an LF 2.x protobuf decoder (vendored daml_lf2.proto), including the LF 2.dev additions — the explicit package-import table and curried type application (TApp) — used by SDK 3.5 DARs. Decodes a DAR’s whole dependency closure (decode_all).
  • canton-daml — the runtime generated code depends on (as rt): the Daml primitive types (Party, ContractId<T>, Numeric, Timestamp, Date, TextMap, GenMap, NestedOpt, Unit), the ToValue/FromValue gRPC codecs and serde JSON conventions (LF-JSON: Int64 and Numeric as strings — accepting a number on input — the nested-optional list form, Unit as {}, adjacently-tagged variants, …), the Contract/Template/Interface/ WithKey/Choice traits, and the create_command/exercise_command/ exercise_by_key_command builders.
  • canton-codegen — DAR → typed Rust: a decoder-agnostic IR, the documented Daml-LF → Rust type mapping (see docs/daml-lf-type-mapping.md), and emission of records, variants, enums, templates (payload + typed choices + on-ledger id), interfaces (marker + view + choices), and contract keys, with JSON and gRPC codecs. Output is a fully-qualified pub mod tree (crate::<package>::<module>::<Type>) so cross-package references resolve and names never collide; template ids use the SCU-friendly #<package-name> form. The package segment is the package name — a version bump does not rename the paths a consumer imports — with the version appended only to separate two packages that would otherwise share a module name. Each template’s docs carry its on-ledger id and its choices with their consuming flag.
  • canton-codegen-cli — the dpm-codegen-rust binary (a dpm codegen-rust component / build-script tool) that writes a self-contained bindings crate from a DAR.
  • Pre-built bindings crates (checked-in generated output, drift-guarded): canton-splice-amulet, canton-splice-wallet, canton-splice-wallet-payments, and canton-quickstart-licensing.
  • canton-sample — reference app: builds a typed AppInstallRequest from the bindings, round-trips both codecs, and runs the full verification loop (submit → observe transaction → query ACS) over gRPC and JSON.
  • Verified on a live cn-quickstart participant: the whole-DAR closure compiles for all corpus DARs, and the sample’s typed create commits and is read back from the ACS on both transports.

Added — a local network needs no configuration in your program

  • canton_core::localnet reads a Splice LocalNet out of the environment — the variables canton-devkit’s localnet env exports. After eval "$(canton-devkit localnet env demo)", Config::from_env() is a working gRPC configuration and JsonClient::from_env() its JSON counterpart, with the participant’s token attached; Config::from_env_for("app-user") reaches the other participant, and localnet::party(alias) the on-ledger id an application needs for act_as. CANTON_ENDPOINT / CANTON_TOKEN override the lot for an environment that is not a LocalNet. Runnable as the localnet example.
  • Config accepts a scheme-less host:port, which is what a gRPC client dials and therefore what tooling hands out — CANTON_GRPC_LEDGER_API_URL is exactly this shape. Previously the missing scheme surfaced at the first RPC as an unexplained transport error; the scheme now defaults to the one TLS configuration implies.

Security

  • canton-lf (zip bomb): a DAR is now bounded in total, not only per entry. Every .dalf is read into memory, so an archive of many entries each legal on its own — a thousand at 256 MiB — passed every check and still asked for hundreds of gigabytes; zeros DEFLATE at roughly 1000:1, so the file carrying that request arrives small enough to go unnoticed. Each read is now capped by whichever of the per-entry ceiling and the remaining archive budget (2 GiB) is smaller, and the error says which limit was reached. The ceiling is ~50× the entire available corpus (41 MiB across 18 DARs), so it cannot fire on a real DAR. Found by reading canton-devkit’s DAR reader, which has had the aggregate cap all along.

  • canton-core: the mutual-TLS private key no longer reaches logs. TlsConfig derived Debug and holds client_identity_pem, so format!("{config:?}") — or one tracing field capturing a Config — printed the key byte by byte; Config leaked it too, along with any credentials in the endpoint URL. Both now have hand-written Debug that reports presence and length. Five error paths across canton-core, canton-auth and canton-ledger that quoted a URL verbatim now run it through canton_core::redact_url. Two more types held the same secret behind a Debug and were missed the first time: JsonClient derived one and printed its base_url — userinfo included — and OidcConfig redacted its client_secret field while printing a token_url that, for a provider taking client credentials as basic auth, is the secret. TokenResponse no longer derives Debug at all; it is one bearer token, and nothing should print it.

  • canton-daml: a type mismatch no longer copies the offending value into the error message. mismatch() formatted it with prost’s Debug, so decoding a payload as the wrong type put the whole record — parties, amounts, free text — into a string that travels into the application’s traces and metrics. It now names the kind and stops.

  • canton-core (bearer token in Debug): AuthInterceptor derived Debug over the token it injects into every gRPC request. The interceptor lives inside every client that holds a channel, so one {:?} on client state — a tracing field, a panic message, an error context — printed a live credential, and SECURITY.md’s “Debug output is redacted” was untrue as written. It now reports presence only. Reported privately by Equilibrium during their M1 review, alongside the mutual-TLS key above.

  • canton-core (panic on a hostile retry hint): Error::retry_delay() handed a server-supplied number to Duration::from_secs_f64, which panics on anything a Duration cannot hold — so a JSON error body carrying "retryInfo": "1e300 seconds" aborted the caller inside error classification, which the retry loop calls on every retriable failure. The conversion is fallible now and an out-of-range hint reads as no recommendation.

Fixed — from Equilibrium’s independent M1 review

An engineering review of the released 0.1.4 client by Equilibrium, carried out on the Development Fund milestone issue. Every finding is closed here; two of them (the Debug leak and the retry-hint panic) are in Security above.

  • Ambiguous submissions are recoverable. A submission whose response is lost may still have committed, and the change ID is the only way back to the outcome — but the SDK generated the command id inside the call that failed and returned it only on success. CantonClient::submission and JsonClient::submission fix the identity first and hand back a Submission carrying its ChangeId, with recover reading the completion back. The existing client methods are thin wrappers over the same object.

  • Recovery matches the whole change ID. await_completion compared command_id alone; Canton identifies a command by (user, acting parties, command id), and two applications on one participant may each use daily-run. It now takes a &ChangeId. A user id left to the bearer token is not compared (the participant resolved it and the client cannot know it), and a completion carrying no acting parties is not rejected on that ground.

  • The resumable update stream honours checkpoints. updates_with dropped OffsetCheckpoint frames before the resumable wrapper could see them, so the resume point only advanced when a transaction arrived — on a quiet stream a reconnect went back to where the caller started, which after pruning fails outright. The resumable path now reads the unfiltered stream and filters for itself. Subscribers see no change.

  • A spent reconnect budget reports the participant’s failure, not UnexpectedResponse("failed to resume after N reconnects"), which threw away the status, the details, the correlation id and the retriable classification at the moment they were needed. Same fix in the resumable ACS read.

  • The JSON lane gained the four operations it was missing: submit (/v2/commands/async/submit), submit_and_wait, events_by_contract_id, and recovery through JsonClient::submission. All four exist in Canton 3.5.7’s JSON API; this was the SDK stopping short.

  • JsonClient::ws_active_contracts_resumable resubscribes from the last streamContinuationToken rather than restarting the snapshot, which the gRPC lane has done since M1.

  • The typed ACS read is lossless. Every gRPC ACS method matched ActiveContract and dropped the rest, so a reassignment in flight at the snapshot offset — IncompleteUnassigned / IncompleteAssigned — vanished from a multi-synchronizer application’s view. AcsEntry and the acs_page / acs_entries / acs_entries_resumable family are the lossless read; the active-only methods keep their names and are now that read with into_active applied.

  • The Auth0 and Okta presets produce their providers’ normal requests. Auth0 needs an audience (without one it issues a token for its own userinfo endpoint, which no participant accepts) — auth0 now takes it, which is a breaking signature change. Okta reads the credentials from an Authorization: Basic header and rejects them in the body as invalid_client; the preset selects that, and ClientAuth exposes the choice for custom endpoints.

  • Telemetry covers a stream’s life, not its opening. instrument_stream counts errors that arrive after a subscription opens — previously a stream that failed an hour in had been recorded as a success and never revisited. The WebSocket upgrade carries traceparent (the only request a WS stream makes), structured events carry trace_id, and otel::otlp_metrics is a supported OTLP path for the counters, recorder and all.

  • A retry the participant de-duplicates is a success, not a failure. This is the other half of the finding, and the half that was still open after the recovery handle was added. When the SDK retries a submission whose response was lost, the participant refuses the second attempt as DUPLICATE_COMMAND — because the first one was accepted. submit was reporting that rejection to the caller, which says the command did not happen at the exact moment it provably did. It now reports success, over both transports. A duplicate on the first attempt is untouched: nothing of ours is at the participant, so the caller reused a change id and needs to hear about it.

    The waiting variants cannot do this — their result is a transaction, and a de-duplicated retry does not carry one — so they surface the rejection and their documentation now says to recover through the handle rather than describing a caveat and leaving it there.

  • examples/recover_a_submission.rs walks the finding’s own scenario end to end: submit, submit the same change ID again (what a retry after a lost response looks like to the participant), watch it be rejected as DUPLICATE_COMMAND, and recover the original outcome. Verified live — the recovered update_id is the one the first submission committed. The README’s other examples are backed by compiled example files; this one was not, and the newest API is the worst one to leave uncompiled.

  • The facade reaches what the documentation promises. canton::telemetry was not re-exported, so cargo add canton could not see the metric names, the transport labels, or — now — the OTLP setup those metrics are meant to be exported through. The otel feature also reached canton-core only by way of canton-ledger, which was correct by accident. Both fixed, with tests that name the paths a reader of the README would try.

From the same review’s non-blocking list:

  • Requests the participant would certainly refuse are refused locally: a submission with no commands or no acting party, both minimum-ledger-time forms at once, a negative offset, an inverted range, a subscription filtered to nobody.
  • read_as reaches the transaction filter of a submission’s response, matching the Ledger API’s own default; filtering to act_as alone returned a transaction quietly missing events.
  • The idempotent reads — events-by-contract-id, the ACS page, the updates page — take the configured retry policy, which had applied only to version, the health check, ledger_end and submissions.
  • Jitter never brings a retry back before a server-recommended delay; a RetryInfo is a minimum, and coming back early spends an attempt on a guaranteed rejection.
  • The WebSocket streams take their reconnect budget and backoff from the client’s RetryConfig instead of a hardcoded five-at-250ms.
  • list_known_parties fails on a repeated page token instead of returning a prefix as if it were the whole list, and a topology response missing a required field fails the read rather than shrinking it.
  • The vendored .proto files carry a provenance record and per-file SHA-256s, verified by a test, with tools/vendor-protos.sh for the refresh.
  • CANTON_TEST_REQUIRE_LIVE=1 turns a skipped live test into a failure, so a live run’s result is a claim about a participant rather than about an empty environment.
  • CI checks aarch64-unknown-linux-gnu and x86_64-unknown-linux-musl, and the cargo-semver-checks job is enabled now that a baseline exists.
  • ADR-0005 no longer claims mixed installs “fail to resolve”; with caret requirements mixed patch versions resolve, which is intended.
  • canton-admin documents that party management here is allocation and discovery, and why updates are out of scope for M1.
  • The reference app reads its committed transaction back independently and matches the exact update id, on both transports.

Fixed — from an external review of the M1 client

  • canton-core (message size): the gRPC decode limit is raised off tonic’s 4 MiB default to 128 MiB, configurable via Config::with_max_decoding_message_size. One ACS page arrives as a single message and the participant permits page sizes up to 10 000, so the default was reachable in ordinary use and surfaced as a client-side OUT_OF_RANGE that reads like a server fault. Applied at all 24 service-client constructions across canton-ledger and canton-admin through one macro per crate, so a newly added RPC cannot pick the default back up. The WebSocket lane had the same problem one layer down and no way to fix it: tungstenite’s defaults cap a message at 64 MiB and a single frame at 16 MiB. JsonClient::with_max_decoding_message_size now sets both, and defaults them to the same 128 MiB, so one transport cannot quietly be stricter than the other. (The HTTP lane needs nothing — reqwest puts no limit on a response body.)
  • canton-core (errors): Error::resource_info() exposes the google.rpc.ResourceInfo Canton attaches to failures where “which one?” is the first question — CONTRACT_NOT_FOUND names the contract id. A Vec rather than an Option, because the JSON transport carries a list.
  • canton-ledger (unbounded waits): the JSON lane had no timeout at all. reqwest applies none unless asked, so a participant that accepted the connection and then went quiet held the caller’s task open for the life of the process — while the gRPC channel beside it had bounded the same call at 30s since M1. JsonClient::with_timeout sets it, defaulting to that same 30s, applied per request so it holds whatever order the builders were called in. The WebSocket handshake is bounded by the same value; the stream that follows is a live tail and deliberately is not.
  • canton-core (transport parity): Error::error_info() and Error::code() now answer on the JSON transport too. Both returned None there while returning the real thing over gRPC — so an application that classified errors by error id, exactly as error_info’s documentation tells it to, silently fell back to string-matching the display text the moment it was pointed at the JSON lane. The body spells both (code/context and grpcCodeValue); a redacted error’s literal "NA" is reported as no error id rather than as one. A live test now asserts that both lanes describe the same failure identically, so this cannot drift apart again unnoticed.
  • canton-daml (party ids): Party::parse / "…".parse::<Party>() validate a party id a caller supplies — refusing empty, over-long, and characters Canton does not use — while Party::new still takes a wire value as-is, the same asymmetry Numeric already had. FromStr was Infallible, so "".parse::<Party>() succeeded and the failure surfaced only at the participant, as a PermissionDenied naming nothing. An empty party id is the shape std::env::var returns for PARTY=, which is how it happens in practice. Idea taken from zenith-network/canton-rs, which validates its identifier types.
  • canton-codegen (submit vs read): the two encoders a generated template carries are now pinned to agree. The emitter writes the payload’s field list twice — in ToValue and in Template::to_record — and they are different paths at runtime: create_command submits to_record, while a contract read back arrives through from_value. A template whose two lists drifted would write one shape to the ledger and expect another, and nothing compared them: every round-trip test goes to_value → from_value, which is the half the submit path does not use.
  • canton-daml (decode errors in containers): a failure inside a List, TextMap or GenMap now names the element. Generated records attach the field name to every decode, but containers dropped everything below it, so one bad entry in a list of five hundred holdings reported that the list was bad and left the reader to find which. The paths compose: holders.2. A list reports the index, a TextMap the key (which locates an entry better than a position), a GenMap the position and which half of the entry — its keys are arbitrary values, so there is no name to point at.
  • canton-daml (dead API removed before it froze): record_field and record_value are gone from the generated-code surface. The emitter never emitted either, nothing in the workspace called them, and the crate is about to publish — after which they would have to keep working forever.
  • canton-codegen (drift guard, published crates): a CI job fetches the three DARs the published bindings were generated from — cn-quickstart tracks them in git — at a pinned commit, verifies their SHA-256, and runs the guards against them, asserting each one ran rather than skipped. Nothing before this checked on any push that the emitter still produces what is on crates.io. (canton-quickstart-licensing stays local-only: its DAR is built from source rather than committed, so there is nothing to pin.)
  • canton-daml (fixture drift): the test fixture that claims to be written “exactly as the generator emits” is now checked against the real emitter instead of asserting it in a comment. It had drifted three times — the .at(label) on each field decode, the serde derives, and the per-field renames — and each drift quietly removed a path from coverage while every test stayed green.
  • canton-codegen (phantom type parameters): a generated codec bounds only the type parameters it actually encodes. Daml permits a phantom parameter — declared but used in no field — and the emitter required T: ToValue for every declared one, so instantiating a phantom parameter with an interface marker produced Rust that does not compile. Markers carry no codec by design: they exist only as the tag of a ContractId. Valid Daml therefore generated invalid Rust, with the error landing in code the reader did not write. Four types across the published bindings carried the spurious bound and have been regenerated without it.
  • canton-codegen (IR semver): every public IR struct is #[non_exhaustive], with a constructor for each. The IR is documented as something a caller lowers and then post-processes, its fields are public, and it gained forty fields during Milestone 2 alone — so once the crate is on crates.io, one more field would be a breaking change for anyone who wrote a struct literal, and adding #[non_exhaustive] afterwards is itself breaking. Fields stay public, so reading and mutating a lowered IR is unchanged; only construction goes through Record::new, Template::new and the rest.
  • canton-codegen (a test that had stopped compiling): the end-to-end test that generates a crate, builds it and round-trips both codecs was gated on CODEGEN_COMPILE_TEST, which nothing set — so it skipped on every push and went stale when the runtime made Numeric and GenMap’s fields private. It now builds its values through the public API, the way a consumer must, and CI sets the variable and asserts the test ran.
  • canton-codegen (manifest injection): the DAR’s package version is validated before it reaches the generated Cargo.toml. It was interpolated raw, so a version of the form 0.1.0" + newline + [dependencies.evil] + git = "… closed the string and opened a table — an arbitrary git dependency in a manifest the caller then compiles, which is code execution from an archive. The archive-integrity guards do not stop it: the package id is the hash of whatever payload its author chose, so an authored DAR passes them all. The crate name beside it was already validated and the runtime path beside it already escaped; the version was the third field in the same manifest and the one that was missed.
  • canton-codegen (drift guard): the guard that keeps the committed bindings honest now runs in CI. The four existing ones need a DAR from a Splice or cn-quickstart checkout, so on a machine without one they skip — and a skipped test reads exactly like a passing one, which meant the property had never been enforced anywhere but a developer’s laptop, for three crates that are published. A fifth guard regenerates from the DAR committed to this repository and AST-compares against a committed fixture, and the CI job asserts it ran rather than skipped, the way the conformance-oracle job already did.
  • canton-daml (typed read on JSON): Template::from_json_created_event is the JSON counterpart of from_created_event, which was gRPC-only. Both transports carry the same contract for the same bindings, so an application may write over one and read over the other — but on the JSON lane a caller had to reach into event["CreatedEvent"]["createArgument"] themselves, and nothing then checked that the event was that template at all. A party’s stream carries every template it sees, and where two payloads share a field shape, decoding one as the other succeeds and is wrong. Accepts the event wrapped (CreatedEvent / createdEvent) or bare, and compares module and entity but not the package id, matching the gRPC path under Smart Contract Upgrade.
  • canton-codegen (shared packages): a package already published as its own crate can be referenced instead of re-generated — Options::with_external_package(name_or_id, crate), or lower_dar_with for the library path. A DAR’s dependency closure is shared, so splice-api-token-holding-v1 sits under amulet, wallet and wallet-payments alike; generating it into each gave each crate its own Holding, and Rust treats those as unrelated types. A program depending on two of the published binding crates failed to compile with “expected Holding, found a different Holding” — for the same interface, in the same package. Packages are keyed by name as well as id, and the name is the one to prefer: an id is the hash of one build, while the name survives a version bump, which is the point of addressing packages by name under Smart Contract Upgrade.
  • canton-codegen (hostile DAR): a type that resolves to itself is refused instead of overflowing the stack. Interned types are a flat table of indices, so interned_types[0] = Interned(0) — or two entries pointing at each other — is not malformed to prost, which bounds nested messages and has no view of the table. Following it recursed until the stack ended, and a stack overflow aborts the process rather than failing the DAR: the one input in this reader that still killed the caller after the zip-bomb, entry-size, archive-size, package-id and LF-version guards. Bounded at 256 levels, where the deepest type in the 648-package corpus resolves in 15.
  • canton-lf (archive integrity): a package’s payload is hashed and checked against the id it declares before it is parsed, and a hash_function other than SHA-256 is refused rather than assumed. That id is embedded in generated bindings as PACKAGE_ID and is what cross-package references resolve through, so a DAR whose id does not match its bytes previously produced bindings naming a package its contents do not answer to.
  • canton-lf (LF version): an archive declaring a Daml-LF 2.x minor this build was not compiled against is refused instead of decoded. prost drops fields from a schema it does not know, so a newer minor yielded a package quietly missing template fields. Accepts 2.1 and 2.2, which is what the available corpus contains (648 packages across 18 DARs). The major version is decided first: an LF 1 archive — every DAR a Daml 2.x SDK built — was being reported as an unsupported LF 2 minor, telling the reader to upgrade an SDK that will never read it.
  • canton-codegen (decode errors): generated FromValue bodies attach the field name to a failure, so a mismatch inside a nested record reports meta.values instead of a bare “expected Text”. ValueError::at existed and was documented as being used by generated code; it was not.

Fixed — pre-release hardening (M2 quality audit)

  • Recursion is now Rust-sound. Optional self-recursion (data Tree = Node { left : Optional Tree }), mutual recursion (including cross-module), and recursion through a generic instantiation previously generated infinitely-sized structs (E0072); a crate-wide containment-cycle breaker now boxes every cycle-closing reference occurrence.
  • Record decode matches Canton’s wire shapes. Generated FromValue locates fields by label or declaration index (non-verbose output omits labels) and decodes an absent trailing Optional field as None (record normalization under Smart Contract Upgrade drops them).
  • Numeric compares numerically, not textually — the ledger echoes "1.5" as "1.5000000000" and the two are now equal (Eq/Ord/Hash on the canonical decimal; Numeric::parse validates early).
  • Typed read path: Template::from_created_event decodes a CreatedEvent into the payload struct (with a template-identity check); ContractId::retag re-tags an id for interface exercise.
  • Codegen robustness on arbitrary DARs: per-entry decompression cap (zip-bomb guard), typed errors naming the offending file/package, $-named GHC-internal types skip instead of panicking the emitter, snake-case field collisions and duplicate package modules are detected, generated code spells std types fully qualified so same-named Daml types cannot shadow them.
  • CLI safety: refuses to overwrite files it did not generate (--force to override); validates the crate name; the generated crate’s version is the DAR package’s version; --runtime-path is absolutized and TOML-escaped.
  • Always-on fixture tests: testdata/splice-api-token-holding-v1-1.0.0.dar runs the full decode → lower → emit pipeline (plus determinism and CLI contract checks) in every cargo test, no external setup.

Changed

  • canton-core gains Error::Payload — the one addition to the surface published in 0.1.x. It carries a canton-daml codec failure as its source, so a typed decode and a transport failure land in the same Result and an application can stay on canton::Error end to end. Error is #[non_exhaustive], so the new variant does not break a match. canton-daml supplies the bridge (impl From<ValueError> for canton_core::Error), and ValueError is structured rather than a string: it carries the field path it failed at and the message separately, so the path survives into the error chain instead of being formatted away.
  • The canton facade re-exports the codegen runtime as [canton::daml], so cargo add canton gets a version-locked runtime for generated bindings. The generator stays out of the facade on purpose: it is a build-time tool (cargo install canton-codegen-cli, or depend on canton-codegen from a build script).
  • The codegen pipeline (generate, Options, Runtime, Stats) lives in canton-codegen, not in the -cli crate, so a build script can call it without pulling in a CLI. canton-codegen-cli is binary-only.
  • Codegen errors are typed: GenerateError and CodegenError replace Box<dyn Error> and syn::Error, and SkippedType (with module() / reason()) replaces a bare string. No syn/proc-macro2 type appears in a public signature.
  • Options uses the same consuming-builder style as M1’s Config, and Options/Stats/Runtime are #[non_exhaustive].
  • Generated crates get a publishable Cargo.toml (description, no forced [workspace] stanza) and their default name drops the DAR’s version suffix, so a DAR bump no longer renames the crate a caller depends on.
  • Every published crate now ships the Apache-2.0 licence text, and integration tests whose fixtures live outside the package are no longer packaged.

Publish order: canton-proto → canton-core → canton-auth → canton-lf → canton-daml → canton-ledger → canton-admin → canton-codegen → canton-codegen-cli → canton → the canton-splice-* bindings. canton-sample and canton-quickstart-licensing stay unpublished (reference material).

[0.1.4] - 2026-08-05

Documentation and developer-experience fixes from the Canton Foundation review of the M1 submission. No library code changes; 0.1.3 and 0.1.4 are the same SDK.

Fixed

  • Examples: version_and_health and submit_and_read read the same CANTON_TEST_* variables as the live tests (the unprefixed CANTON_* spellings still work and take precedence). They previously required a second, undocumented set of six names, so following the README’s own setup and then running the example it links to failed on set CANTON_TOKEN_URL.
  • Live tests: the ACS token-paging test no longer caps the walk at 500 pages. At page size 1 that bounded the ledger rather than the loop, so on a participant with a few hundred contracts a healthy walk reported “paging did not terminate”. It now fails when the page token stops advancing, which is the condition it was meant to catch.

Documentation

  • README: the live-test variables are documented under their real names (every one is CANTON_TEST_-prefixed) in a table that says what each gates, with a copy-pasteable export block. CANTON_TEST_JSON_ENDPOINT (the JSON and WebSocket tests) and CANTON_TEST_SYNC_ID were previously undocumented, and six names were listed without their prefix.
  • README: node setup lists the Canton Builder Tool and Splice LocalNet alongside cn-quickstart, with what each one actually provides: Splice LocalNet is unauthenticated, so the OIDC-gated tests skip there as well as the command-submission ones; the Builder Tool’s canton builder deploy uploads a DAR, which is how to get CANTON_TEST_LICENSING_PKG without cn-quickstart.

[0.1.3] - 2026-08-04

Fixed

  • canton-ledger: CompletionsRequest’s JSON body builder is now gated on the ws feature, which is the only thing that reads it — the JSON Ledger API has no POST endpoint for completions. Without the gate the method was dead code in any build without ws, which failed the workspace’s own -D warnings on the feature powerset. Consumers of 0.1.2 are unaffected: Cargo caps lints in registry dependencies, so a default-features = false build of the published crate compiles either way.

[0.1.2] - 2026-08-04

Additive (semver-compatible) API growth from external M1 review feedback: request builders for the read streams, and Canton-native error classification.

Added

  • canton-ledger (request builders): UpdatesRequest, ActiveContractsRequest, and CompletionsRequest (module request, re-exported at the root) open up the request surface the plain methods hard-code — on every read path and both transports: gRPC (updates_with, updates_resumable_with, updates_page_with, active_contracts_with, active_contracts_page_with, active_contracts_resumable_with, completions_with), JSON POST (JsonClient::updates_with, active_contracts_with), and WebSocket (ws_updates_with, ws_active_contracts_with, ws_completions_with):

    • bounded streams (until(end_inclusive)) for catch-up/sync reads that terminate at an offset instead of staying live;
    • template and interface filters (for_templates / for_interfaces, accepting package:Module:Entity ids with #package-name references) instead of the wildcard filter;
    • transaction shape selection (TransactionShape::AcsDelta / LedgerEffects), created-event blobs, topology events, dropping reassignments, and non-verbose records;
    • UpdatesRequest::descending — bounded newest-first reads on the stream and page paths, both transports (rejected up front on the resumable stream, whose position tracking assumes ascending order);
    • for_any_party (updates and ACS) — the filters_for_any_party / filtersForAnyParty full-ledger ingestion form, for tokens with wildcard read authorization;
    • CompletionsRequest::with_user_id to scope the completion stream to a submitting user id (pairs with Submit::with_user_id) — required for tokens that do not carry a user id.

    Defaults match the plain methods exactly (updates_with(UpdatesRequest:: new(p, o)) ≡ updates(p, o)), whose signatures are unchanged — the plain methods now delegate to the builders, so there is a single body/request producer per query on each transport.

  • canton-ledger (submit): Submit::with_transaction_shape selects the shape of the transaction returned by submit_and_wait_for_transaction (ledger effects — the default, as before — or ACS delta), removing the last hard-coded TransactionShape.

  • canton-ledger (submit, full Commands surface): every remaining Commands field is now settable on both transports — Submit/JsonCommands gain with_submission_id, add_disclosed_contract (the explicit-disclosure counterpart to the created-event blobs the read builders expose), with_package_id_selection_preference (the SCU upgrade pin), and with_min_ledger_time_abs/with_min_ledger_time_rel; Submit additionally gains with_prefetch_contract_keys and with_taps_max_passes, and JsonCommands gains with_deduplication_period (raw JSON, mirroring Submit::with_deduplication_duration/_offset).

  • canton-ledger (JSON retries): JsonClient::with_retry(RetryConfig) — the same opt-in retry pipeline as the gRPC client (category-first classification of the participant’s error body, exponential backoff, server-recommended delays honoured; de-duplication-safe for submits since the command id stays fixed across attempts).

  • canton-core (error classification): errors now expose the precise retry semantics the Ledger API encodes, beyond the transport status code — on both transports:

    • ErrorCategory — Canton’s error categories (from ErrorInfo.metadata["category"] on gRPC statuses, or the errorCategory field of JSON API error bodies) with the retryability the error-code docs define; surfaced as Error::category().
    • Error::retry_delay() — the server-recommended pause, from the google.rpc.RetryInfo detail (gRPC) or the retryInfo field (JSON).
    • Error::correlation_id() — from the google.rpc.RequestInfo detail (gRPC) or correlationId/traceId (JSON), for quoting to the participant operator.
    • Error::is_retriable is now category-first for both Error::Status and Error::Http: the category’s verdict wins over the gRPC/HTTP status code (e.g. a category-12 “seek after ledger end” on HTTP 400 is retryable; a category-10 “resource exists” on ABORTED is not), a bare RetryInfo counts as retryable, and the previous code-based classification remains as the fallback for non-Canton errors. Behaviour change under opt-in retry: HTTP errors that Canton marks retryable now retry where they previously failed fast — including the 413 JSON_API_MAXIMUM_LIST_ELEMENTS_NUMBER_EXCEEDED (category 2 with a 1-second retryInfo), even though shrinking the request limit is the actual remedy. The SDK follows the server’s verdict rather than special-casing codes; callers that want to fail fast on it can match on Error::Http { status: 413, .. } or handle it before retrying.
    • retry::run_with_retry honours the server’s retry_delay when it exceeds the local backoff.

Fixed

  • canton-ledger (WebSocket error frames): a JsCantonError frame on a WS stream is now surfaced as Error::Http (with the gRPC code’s canonical HTTP status and the full error body), instead of Error::CommandRejected. The frame is the same error object the JSON API returns in HTTP error bodies, so it now carries its category()/retry_delay()/ correlation_id() — and, behaviour change, ws_updates_resumable now correctly reconnects on transient participant errors (categories 1/2/…), which the never-retriable CommandRejected mapping previously turned into a dead stream.

[0.1.1] - 2026-07-22

Hardening patch over 0.1.0, from an adversarial pre-release review. Each fix has a regression test. Semver-compatible, so ^0.1 resolves to it.

Fixed

  • canton-core (TLS): Config::with_tls no longer connects in plaintext on an http:// endpoint. tonic selects the TLS handshake from the URI scheme, not from the presence of a tls_config, so TLS was silently ignored (no encryption, no server-certificate verification, no client certificate for mutual TLS). The scheme is now normalised to https:// when TLS is configured, case-insensitively.
  • canton-ledger (JSON and WebSocket TLS): the same normalisation on JsonClient::with_tls. An http:// base URL previously sent plaintext HTTP with the configured certificates unused and opened a ws:// socket; the base URL is now upgraded to https:// so both the HTTP and WebSocket lanes use TLS.
  • canton-ledger (JSON updates): the JSON update stream now requests includeReassignments, matching the gRPC transport. Behaviour change: a JSON update stream now surfaces the assigned/unassigned reassignment events it silently dropped before.
  • canton-auth: the cached-token TTL is clamped to 30 days, so a token endpoint reporting an absurd expires_in can no longer overflow the cache deadline and panic.
  • canton-core (retry): Error::is_retriable now treats the whole 5xx range as retriable (plus 408 and 429), instead of a hand-picked subset that missed codes such as 501, 507, 509, 511, and 520.

[0.1.0] - 2026-07-20

Added — Milestone 1: core Ledger API client, auth & PoC

  • canton — the SDK entry point: a thin facade (re-exports only, no logic) over the crate family — canton::ledger / canton::auth / canton::admin, the shared Config/Error/TLS/retry types at the root, and the ws/otel features forwarded (ADR-0007).

  • canton-ledger — async Ledger API v2 client over gRPC:

    • Command submission: fire-and-forget submit, submit_and_wait (update id), and submit_and_wait_for_transaction; change-ID de-duplication; completion stream + await_completion recovery; the Submit builder (act_as/multi-party, user_id, read_as, workflow_id, synchronizer_id, de-duplication period).
    • Reads & streams: ACS snapshot + paging (+ resumable paged ACS), updates stream + paging + reverse-order (+ resumable from the last offset), event query by contract id, node health (grpc.health.v1).
    • JSON transport: command submission, bounded ACS/update reads, and — behind the ws feature — WebSocket streaming (updates, ACS, completions, resumable updates), TLS-aware.
    • TLS/mTLS on gRPC, HTTP, and WebSocket from one shared TlsConfig.
  • canton-core — shared kernel: typed Error with retriable/non-retriable classification and structured google.rpc.ErrorInfo details; Config (endpoint, auth, TLS, timeout); opt-in retry with per-attempt timeouts and jittered exponential backoff; telemetry (tracing spans + metrics counters per method/transport, optional OTLP export + W3C trace-context propagation behind otel).

  • canton-auth — OIDC client-credentials TokenProvider with caching, refresh, bounded fetch, credential-rejection surfacing (Error::Auth), and Keycloak/Auth0/Okta presets.

  • canton-admin — party allocation/management, user self-inspect, packages read, and topology read (party→participant mappings, namespace delegations, vetted packages) over the Canton admin API.

  • canton-proto — generated types/stubs from vendored protos pinned to Canton 3.5.7 (Ledger API v2, Canton admin-api topology read, gRPC health).

  • Runnable examples (version_and_health, submit_and_read), CI (fmt/clippy/tests on Linux+macOS+Windows/docs/deny/MSRV/feature matrix).