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:
| Crate | For |
|---|---|
canton-ledger | The Ledger API client: • submit, recover an ambiguous submission • read the active contract set • stream updates, resumably • point reads • interactive submission |
canton-auth | OIDC client-credentials tokens, cached and refreshed, with Keycloak, Auth0 and Okta presets. |
canton-daml | The runtime under generated bindings: • Daml types, Template and Choice• command builders • the JSON and gRPC value codecs |
canton-codegen-cli | dpm-codegen-rust: turns a DAR into a typed bindings crate. |
canton-token | Token-standard workflows over the registry API: transfers, allocations, settlement. |
canton-signer | The Signer trait for external signing, with an Ed25519 implementation. |
canton-admin | Parties, packages and users over the Ledger API’s admin services, and topology reads over the Canton admin API. |
canton-pqs | Typed 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
- New to the SDK: Getting started, then Local development and testing to get a participant to talk to.
- Building against your own Daml model: Typed bindings from a DAR.
- Moving tokens: The token standard.
- Keys held outside the participant: Interactive submission and external signing.
- Which Canton, Daml-LF and Rust versions are supported: the compatibility matrix.
- How the SDK is maintained, what that covers and what it does not: Maintenance.
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
| Feature | Crate | What it adds |
|---|---|---|
ws | canton-ledger | WebSocket streaming for the JSON transport (ws_updates, ws_active_contracts, ws_completions, ws_updates_resumable). You also need it to recover a JSON submission. |
otel | canton-core, canton-ledger | OTLP span export (telemetry::otel::otlp_tracer), and W3C trace-context injection into outgoing gRPC metadata and JSON headers. |
ed25519 | canton-signer | The in-memory Ed25519 key (Ed25519Key), built on ring. On by default through the facade. |
tls | canton-pqs | Connecting 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:
| Method | Effect |
|---|---|
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:
| Constructor | Token endpoint | Notes |
|---|---|---|
OidcConfig::new(token_url, client_id, secret) | token_url | Sends the credentials in the request body |
OidcConfig::keycloak(base_url, realm, client_id, secret) | {base_url}/realms/{realm}/protocol/openid-connect/token | The LocalNet IdP |
OidcConfig::auth0(domain, audience, client_id, secret) | https://{domain}/oauth/token | Sends 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/token | Sends 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:
| Method | Waits for | Returns |
|---|---|---|
submit | Nothing | The command id, to find the completion with later |
submit_and_wait | The completion | update_id and completion_offset |
submit_and_wait_for_transaction | The transaction | The 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,TransportandConnectionare retriable.- For a gRPC
Statusor a JSONHttperror, Canton’sErrorCategorydecides. Without a category, the gRPC codesUnavailable,DeadlineExceeded,ResourceExhaustedandAbortedare retriable, as is a call whose connection dropped mid-flight (anUnknownorInternalstatus 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: run a LocalNet and the live test suites.
- Reading the ledger: the active contract set, update streams and point reads.
- Typed bindings from a DAR: replace hand-built records with generated Rust types.
- The token standard: transfers, allocations and settlement.
- Interactive submission and external signing: keys held outside the participant.
- Querying PQS: typed queries against the Participant Query Store.
- Admin API and telemetry: parties, packages, topology, spans and metrics.
- API reference: every crate on docs.rs.
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:
| Tool | Start it | App Provider participant | Credentials |
|---|---|---|---|
| canton-devkit | canton-devkit localnet up demo (or dpm localnet up demo) | its own ports; read them from the export, or choose them with --port-base | Splice’s unsafe-jwt-hmac-256; tokens are exported ready-made |
| Canton Builder Tool | canton builder start; canton builder status prints the ports | 3901 gRPC, 3902 admin, 3975 JSON | canton builder token --validator app-provider prints a bearer token whose user carries ParticipantAdmin |
| Splice LocalNet | Docker Compose | 3901 gRPC, 3902 admin, 3975 JSON | none by default; the other profile, unsafe-jwt-hmac-256, has you sign tokens yourself |
| cn-quickstart | make setup && make build && make start | 3901 gRPC, 3902 admin, 3975 JSON | Keycloak: 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.
| Variable | Meaning | Read by |
|---|---|---|
CANTON_GRPC_LEDGER_API_URL | gRPC Ledger API, scheme-less host:port | Config::from_env, localnet::grpc_endpoint(None) |
CANTON_JSON_LEDGER_API_URL | JSON Ledger API, with an http(s):// scheme | JsonClient::from_env, localnet::json_endpoint(None) |
CANTON_<ROLE>_JWT | that role’s bearer token | both of the above, localnet::token |
CANTON_<ALIAS>_PARTY | an on-ledger party id | localnet::party |
CANTON_INSTANCE, CANTON_SPLICE_VERSION | which network this is | localnet::instance, localnet::splice_version |
CANTON_ENDPOINT | overrides the gRPC URL (not the JSON one) | Config::from_env, localnet::grpc_endpoint |
CANTON_TOKEN | overrides the token, on both transports | Config::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:portis what a gRPC client dials, so that is what the exporter prints.Configkeeps it as given and the channel builder addshttp://, orhttps://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) orHost(HTTP) header. Do not substitute127.0.0.1: that reaches the port and is refused by the virtual host.*.localhostresolves to loopback on macOS and on Linux with systemd-resolved; elsewhere, add an/etc/hostsentry. - A set-but-empty variable counts as unset. An empty
CANTON_ENDPOINTorCANTON_TOKENleft 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_envthen returns a configuration with no credentials. - A missing endpoint is.
from_envreturnsError::InvalidRequestnaming the variable it looked for and thecanton-devkit localnet envcommand 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.
| Suite | Command | Needs |
|---|---|---|
| Ledger API | cargo test -p canton-ledger --all-features --test live | a participant |
| Interactive submission | cargo test -p canton-ledger --all-features --test interactive_live | a participant; ParticipantAdmin to onboard an external party |
| Admin API | cargo test -p canton-admin --all-features --test live | a participant, and its admin port for topology reads |
| PQS | cargo test -p canton-pqs --all-features --test live | a Scribe store following the participant |
| Token standard | cargo test -p canton-token --all-features --test live | a 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.
| Variable | What it gates | Falls back to |
|---|---|---|
CANTON_TEST_ENDPOINT | every gRPC test in the ledger, interactive and admin suites | CANTON_ENDPOINT, then CANTON_GRPC_LEDGER_API_URL |
CANTON_TEST_JSON_ENDPOINT | the JSON-transport and WebSocket tests | CANTON_JSON_LEDGER_API_URL |
CANTON_TEST_TOKEN_URL, CANTON_TEST_CLIENT_ID, CANTON_TEST_CLIENT_SECRET | OIDC client credentials | the exported token: CANTON_TOKEN, then CANTON_APP_PROVIDER_JWT |
CANTON_TEST_PARTY | command submission and read-back | CANTON_APP_PROVIDER_PARTY |
CANTON_TEST_LICENSING_PKG | the tests that submit commands: the package they instantiate, #quickstart-licensing | nothing |
CANTON_TEST_ADMIN_CLIENT_ID, CANTON_TEST_ADMIN_CLIENT_SECRET | party management and external-party onboarding, which need ParticipantAdmin | an exported token, after asking the participant whether it carries ParticipantAdmin; on an unauthenticated participant the interactive suite needs nothing |
CANTON_TEST_ADMIN_ENDPOINT | canton-admin topology reads (3902 on a LocalNet) | nothing |
CANTON_TEST_SYNC_ID | optional: also assert vetted packages in the synchronizer store | nothing |
CANTON_TEST_CONTRACT_KEYS_PKG | the interactive suite’s contract-key test: #contractkeys-h61a17c769e62 | nothing |
CANTON_PQS_URL | the PQS suite: a postgres:// URL or a libpq host=… port=… user=… password=… dbname=… string | nothing |
CANTON_TOKEN_REGISTRY_URL | the token suite: http://scan.localhost:4000 on a Splice LocalNet or Canton Builder Tool | nothing |
CANTON_TEST_REQUIRE_LIVE | turns 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.shbefore querying. Scribe applies its schema and ingests from the ledger’s beginning first; the script waits, up to six minutes, untillatest_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 athttp://wallet.localhost:3000). An empty store fails the suite. SCRIBE_SOURCE_LEDGER_HOSTandSCRIBE_SOURCE_LEDGER_PORTpoint Scribe at another participant;SCRIBE_IMAGEandSCRIBE_VERSIONpick the image.- cn-quickstart runs a store of its own. Point
CANTON_PQS_URLat 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:
- Compiles every live suite first, before the LocalNet takes the memory.
- Boots a LocalNet with one validator, a PQS store and a party, using the canton-localnet action pinned to a commit.
- Maps the action’s
CANTON_LOCALNET_*exports to theCANTON_TEST_*,CANTON_PQS_URLandCANTON_TOKEN_REGISTRY_URLnames above. A missing export fails this step, instead of reaching a suite as an empty value. - Checks the licensing and contract-keys DARs against their
.sha256files and uploads both over the JSON API. It then confirms the participant holds the package idcanton-quickstart-licensingpins, so the committed DAR and the committed bindings cannot drift apart. - Taps Amulet into the validator’s wallet, for the PQS suite to read.
- Runs each suite with
CANTON_TEST_REQUIRE_LIVE=1and 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. - 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 method | Effect |
|---|---|
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 method | Effect |
|---|---|
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::AcsDeltagives 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:
UpdateisTransaction,Reassignment,TopologyTransactionorOffsetCheckpoint, withoffset(),update_id()andas_transaction().Transactioncarriesupdate_id,command_id,offset,synchronizer_id,effective_at,record_time,eventsand more, and iterates one kind of event withcreated(),archived()andexercised().EventisCreated,ArchivedorExercised.CreatedEvent::decodedecodes the payload as a generated template type, with the same identity checks asfrom_created_event.ExercisedEventhasdecode_argumentanddecode_result.- For data these types do not model, the original wire form is still
available through
as_grpc()andas_json()onCreatedEvent,ReassignmentandTopologyTransaction. A reassignment’s or topology transaction’s events are only available that way.
Three reads return these types:
| Method | Returns |
|---|---|
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’sOffsetCheckpointmessages, 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
RetryConfigmax_attemptsandinitial_backoff(set withConfig::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 anError::InvalidRequestitem. These methods are notasync, 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)andupdate_by_id(update_id, parties)use the default format ofUpdatesRequest::new.update_by_offset_with(offset, &request)andupdate_by_id_with(update_id, &request)take their format from anUpdatesRequest: 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 reads | Streams. Live unless the request has until. | updates, updates_with and updates_typed_with return a Vec from one HTTP response. |
| Size limit | Streams 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 tail | updates* | ws_updates, ws_updates_with (feature ws). |
| Resumable updates | updates_resumable*, with any UpdatesRequest. | ws_updates_resumable(parties, begin). Takes no builder, yields raw JSON, and also reconnects after a clean close. |
| ACS | Streamed 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 paging | updates_page*, active_contracts_page*, acs_page* | None. |
| Checkpoints | Dropped by every updates* method. | Included in the raw updates and updates_with arrays. Dropped by updates_typed_with and ws_updates*. |
| Point reads | Option<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 for the generated types used above.
- Querying PQS to filter contracts in a database instead.
- The changelog for what 0.3.2 added.
- The compatibility matrix for verified Canton versions.
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-cliprovides thedpm-codegen-rustbinary, which writes a complete bindings crate from a DAR. Most projects need only this.canton-codegenis the library behind the binary, for tooling that generates crates itself.canton-damlis the runtime the generated code imports asrt: the Daml primitive types, theTemplate/Choicetraits 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.
| Flag | Meaning |
|---|---|
--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). |
--force | Overwrite 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-bindingsdoes not exist yet, so the build fails before the script that would create it runs. - The
prost-buildarrangement (write intoOUT_DIR, theninclude!it) does not apply either. The emitted tree spans several Daml packages and refers between them bycrate::-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:
Template::from_created_eventtakes the gRPCCreatedEvent.Template::from_json_created_eventtakes a JSON created event, either bare or in the wrappers the JSON API uses ({"CreatedEvent": …}in a transaction,createdEventin an ACS entry,{"created": {"createdEvent": …}}from an event query).canton_ledger::update::CreatedEvent::decodeworks on the SDK’s transport-neutral update types.ExercisedEventhasdecode_argumentanddecode_resultfor the choice argument and its result. See Reading the ledger.
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-paymentsfor the Splice application DARs.canton-splice-api-token-*-v1and-v2for the token-standard packages (CIP-56 and CIP-0112), andcanton-splice-api-featured-app-v1.canton-daml-stdlibfor 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}");
}
Optionsmirrors the CLI flags:with_crate_name,with_runtime(taking aRuntime,VersionorPath) andwith_force.with_external_packageis 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 thiscomment; pin it to your SDK version.Statsreports the packages, modules and items written and the declarations skipped. Failures are aGenerateError.
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
Optionalbehind 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:
- Send the registry the choice you are about to exercise, with empty
extraArgsas the standard requires. The registry replies with the factory contract id, the choice context, and the contracts the participant must be shown. - 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 thatcanton-damlimplements, so no hand-written mapping is involved.disclosed_contracts()returns the contracts as gRPCDisclosedContracts. The base64createdEventBlobis 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 asUnexpectedResponseand 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.
| Variable | Read by | Meaning |
|---|---|---|
CANTON_TEST_ENDPOINT | all | The participant’s gRPC Ledger API, e.g. http://localhost:3901 |
CANTON_TOKEN_REGISTRY_URL | all | The registry’s base URL |
CANTON_TOKEN | all | A 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_RUN | all | When set, build the command and do not submit it |
CANTON_TOKEN_SENDER | v1_transfer, v2_transfer, v2_allocate, v2_withdraw_allocation | The sending party |
CANTON_TOKEN_RECEIVER | v1_transfer, v2_transfer, v2_allocate | The receiving party |
CANTON_TOKEN_AMOUNT, CANTON_TOKEN_INSTRUMENT | v1_transfer, v2_transfer, v2_allocate | Amount and instrument id, e.g. Amulet |
CANTON_TOKEN_HOLDINGS | v1_transfer, v2_transfer, v2_allocate | Comma-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_ACCOUNT | v2_transfer | V2 account ids. Default to the empty id, which fits a registry with one account per party |
CANTON_TOKEN_EXECUTOR | v2_allocate, v2_settle | The party that settles |
CANTON_TOKEN_SIDE | v2_allocate | sender (default) or receiver |
CANTON_TOKEN_SETTLEMENT_ID | v2_allocate | The settlement to join. Required on the receiver’s side. The sender’s side generates dvp-<micros> when it is unset |
CANTON_TOKEN_SETTLEMENT | v2_settle | Only settle this settlement id |
CANTON_TOKEN_ALLOCATION | v2_withdraw_allocation | Only 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(®istry, &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(®istry, &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 bothownerandproviderare 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_instructionandwithdraw_allocation_instruction.
Transfers over accounts
v2::transfer(®istry, 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(®istry, 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(®istry, 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(
®istry,
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(®istry, 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(
®istry,
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(®istry, &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(®istry, &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::types | Generated crate |
|---|---|
metadata (ExtraArgs, ChoiceContext, Metadata) | canton-splice-api-token-metadata-v1, shared by V1 and V2 |
v1::holding | canton-splice-api-token-holding-v1 |
v1::transfer_instruction | canton-splice-api-token-transfer-instruction-v1 |
v1::allocation | canton-splice-api-token-allocation-v1 |
v1::allocation_instruction | canton-splice-api-token-allocation-instruction-v1 |
v2::holding (Account, Holding) | canton-splice-api-token-holding-v2 |
v2::transfer_instruction | canton-splice-api-token-transfer-instruction-v2 |
v2::allocation (SettlementInfo, TransferLeg, FinalizedAllocation) | canton-splice-api-token-allocation-v2 |
v2::allocation_instruction | canton-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.
| Crate | Its part |
|---|---|
canton-admin | Onboards the external party: AdminClient::generate_external_party_topology and allocate_external_party. |
canton-ledger | Prepare, sign, execute: Prepare, Prepared, Executable, and the CantonClient methods that send them. |
canton-signer | The 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:
generate_external_party_topology(synchronizer, party_hint, &public_key)returns anExternalPartyTopology: 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.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 authorizesAllocateExternalPartyfor an identity-provider admin, or for a token whose own user is theuser_idpassed. A LocalNet without authentication needs nothing beyond the endpoint. user_idgetsact_asrights on the new party; an empty string grants none. Canton derives the ledger user from the token’ssub, 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_partyis not retried: the request has no idempotency key. If the response is lost, look the party up withAdminClient::get_partiesbefore 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:
| Method | Effect |
|---|---|
with_command_id | The 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_id | As on an ordinary submission. |
with_disclosed_contracts | Explicitly 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_rel | Lower bound for ledger time. Exclusive; the later call wins. |
with_max_record_time | The record time after which the participant refuses the prepared transaction. |
with_hashing_scheme_version, trust_participant_hash, with_verbose_hashing | See Hashing schemes. |
with_package_id_selection_preference, with_prefetch_contract_keys, with_taps_max_passes, without_traffic_cost_estimation | The 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 method | Returns |
|---|---|
execute_submission | () once the participant has accepted the transaction. Acceptance is not commitment: the outcome arrives on the completion stream. |
execute_submission_and_wait | The update id and the completion offset, once the transaction has committed. |
execute_submission_and_wait_for_transaction | The 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.
| Scheme | What the SDK does | Use it for |
|---|---|---|
| V2 | The default. Recomputes the hash and refuses a mismatch. | Transactions without contract keys. |
| V3 | Recomputes 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:
signreceives a hash the participant produced:prepared_transaction_hashfor a submission, the multi-hash for onboarding. Sign those bytes as they are; do not hash them again.fingerprintis the one Canton assigned, fromExternalPartyTopology::public_key_fingerprint(). For onboarding, describe the key withPublicKey::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, soConcatfor ECDSA, orDerfor EdDSA, fails verification. Signature::newrefuses empty bytes and an Ed25519Concatsignature 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"] }
| Feature | Crate | What it adds |
|---|---|---|
tls | canton-pqs | PqsClient::connect_tls, verified against the platform’s root certificates |
pqs-tls | canton | Forwards 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 withError::Timeout.with_timeoutchanges 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
Debugoutput 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:
| Accessor | What 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");
| Predicate | Compiles to | Use it for |
|---|---|---|
Predicate::eq(path, value) | payload #> $p = $v | exact JSON equality on a field |
Predicate::not_eq(path, value) | payload #> $p IS DISTINCT FROM $v | inequality, including rows where the field is absent |
Predicate::compare(path, op, value) | payload #>> $p > $v, numeric cast for numbers | ordered comparisons (Op::Lt, Le, Gt, Ge) |
Predicate::contains(json) | payload @> $v | JSONB containment |
Predicate::signatory(party), observer, witness | $v = ANY(signatories) and so on | party columns |
Predicate::contract_id(id) | contract_id = $v | one 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, so100sorts above9. If you pass a string, the comparison is textual, so do that only for fields that really are text. eqandnot_eqturn a JSON number into its string form, which works forInt64. ANumericis stored with its full scale (an Amulet amount reads"504680.1600000000"), soeqonly matches that exact text. For numeric equality, bound the field on both sides withOp::GeandOp::Le.containsdoes 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:
| Situation | Error | Retriable |
|---|---|---|
| store unreachable | Connection | yes |
| no answer within the timeout | Timeout | yes |
SQLSTATE class 08, 40, 53, 55, 57 (connection loss, serialization failure, too many connections, lock unavailable, shutdown) | Connection | yes |
| connection string does not parse | InvalidRequest | no |
wrong password, unknown database, pg_hba.conf rejection | InvalidRequest | no |
| certificate does not verify, or the store refuses TLS | InvalidRequest | no |
| any other rejected statement (syntax, unknown function or column, bad cast) | InvalidRequest | no |
a payload that does not decode into T | Payload | no |
| a column missing from the row | UnexpectedResponse | no |
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 withANDonly.limitfixes the order but takes no cursor or SQLOFFSET. UseSql::rawfor 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:
| Client | Endpoint | Covers |
|---|---|---|
AdminClient | the Ledger API port (3901 on LocalNet), with the same endpoint and auth as canton-ledger | parties, user self-inspect, package reads, external-party onboarding |
TopologyClient | the 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
| Calls | What the token needs |
|---|---|
current_user, current_user_rights, acting_parties, participant_id | any authenticated user |
list_packages, get_package, get_package_status | any 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_page | ParticipantAdmin |
get_parties | ParticipantAdmin 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_party | ParticipantAdmin (how the live tests run them) |
get_user / list_user_rights for another user’s id | ParticipantAdmin or identity-provider admin. An empty id means your own user, which any token may read. |
every TopologyClient call | whatever 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_packagesreturns the ids of every package the participant knows. It returns ids only.get_packagedownloads one package asArchivePayloadbytes. 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 withError::UnexpectedResponse. Asking by id therefore pins the content, and you need no separate checksum.get_package_statusreturns aPackageStatus:Registeredfor a known package. An unknown id is not an error; its status is something other thanRegistered.
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_partyis never retried, even when aRetryConfigis 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_partiesfollows pagination for you. It fails instead of returning a partial list if the participant repeats a page token or keeps going pastcanton_core::MAX_LIST_PAGESpages. To page by hand, uselist_known_parties_page, where apage_sizeof0means the server default.get_partiesleaves 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.
Storeselects the store:Authorized(the node’s own state),Synchronizer(physical_id), orTemporary(name). Vetted packages are stored per synchronizer, so querying them againstStore::Authorizedreturns an empty list. - Each result is an
Entrywith the mapping initemand its validity, serial and store incontext. 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.
TopologyClientis 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.
| Span | Fields | Covers |
|---|---|---|
canton.rpc | method, transport | one call, including every retry attempt |
canton.stream | method, transport | the whole life of a stream. Each item is polled inside it. |
| Counter | Labels |
|---|---|
canton_client_requests_total | method, transport |
canton_client_errors_total | method, transport, retriable |
methodis the SDK method name, such asallocate_partyorlist_packages. A convenience call reports under the call it delegates to, socurrent_userreports asget_user.transportisgrpcorjson, and it tells the twins apart, sincelist_packagesexists on both. The names are public constants:METRIC_REQUESTS,METRIC_ERRORS,TRANSPORT_GRPCandTRANSPORT_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 completedandstream endedatdebug,rpc failedandstream failedatwarn, with the error andretriable. All of them have the targetcanton_core::telemetry, so a filter such ascanton_core=debugshows every call. Each event carries atrace_idfield. 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_tracerandotel::otlp_tracer_providerbuild a batching OTLP/gRPC span exporter; take the provider to flush or shut it down.otel::otlp_metricsbuilds an OTLP metrics pipeline and installs it as the process’smetricsrecorder. It fails if a recorder is already installed.- W3C trace context (
traceparentandtracestate) 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_idis filled in, so a log line can be joined to its trace.
| You depend on | Turn on |
|---|---|
canton | features = ["otel"], forwarded to canton-ledger and canton-core |
canton-ledger | features = ["otel"], forwarded to canton-core |
canton-admin without the others | canton-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 | |
|---|---|
canton | The Canton Network Rust SDK: one-stop entry point re-exporting the canton-* crate family. |
Ledger API
| Crate | |
|---|---|
canton-ledger | Async Canton Ledger API client (gRPC): commands, streaming, de-duplication, recovery. |
canton-auth | JWT/OIDC authentication for the Canton Rust SDK (client-credentials, caching, refresh). |
canton-core | Core types for the Canton Rust SDK: error model, telemetry, and the shared connection kernel (config, auth, TLS, retry). |
canton-admin | Canton admin client: party management, user self-inspect, and topology read (gRPC). |
canton-signer | Pluggable transaction signing for Canton interactive submission (HSM/KMS-compatible). |
canton-pqs | Typed read client for the Daml Participant Query Store (PQS/Scribe): typed predicates compiled to parameterized JSONB queries. |
Daml models and codegen
| Crate | |
|---|---|
canton-daml | Runtime types and Value codecs for canton-codegen-generated Daml bindings. |
canton-codegen | Type-safe Rust code generation from Daml packages (DAR to Rust). |
canton-codegen-cli | dpm codegen-rust: generate a typed Rust crate of bindings from a DAR. |
canton-lf | Daml-LF archive (DAR/DALF) reader and decoder for the Canton Rust SDK. |
canton-daml-stdlib | Pre-built typed Rust bindings for the Daml standard library (daml-stdlib, daml-prim, ghc-stdlib). |
Token standard
| Crate | |
|---|---|
canton-token | Canton Network token-standard workflows (CIP-56 and CIP-0112): registry client, choice contexts, transfers and allocations. |
canton-splice-api-featured-app-v1 | Pre-built typed Rust bindings for the Splice splice-api-featured-app-v1 DAR. |
canton-splice-api-token-metadata-v1 | Pre-built typed Rust bindings for the Splice splice-api-token-metadata-v1 DAR. |
canton-splice-api-token-holding-v1 | Pre-built typed Rust bindings for the Splice splice-api-token-holding-v1 DAR. |
canton-splice-api-token-holding-v2 | Pre-built typed Rust bindings for the Splice splice-api-token-holding-v2 package (CIP-0112). |
canton-splice-api-token-allocation-v1 | Pre-built typed Rust bindings for the Splice splice-api-token-allocation-v1 DAR. |
canton-splice-api-token-allocation-v2 | Pre-built typed Rust bindings for the Splice splice-api-token-allocation-v2 package (CIP-0112). |
canton-splice-api-token-burn-mint-v1 | Pre-built typed Rust bindings for the Splice splice-api-token-burn-mint-v1 DAR. |
canton-splice-api-token-transfer-events-v2 | Pre-built typed Rust bindings for the Splice splice-api-token-transfer-events-v2 package (CIP-0112). |
canton-splice-api-token-transfer-instruction-v1 | Pre-built typed Rust bindings for the Splice splice-api-token-transfer-instruction-v1 DAR. |
canton-splice-api-token-transfer-instruction-v2 | Pre-built typed Rust bindings for the Splice splice-api-token-transfer-instruction-v2 package (CIP-0112). |
canton-splice-api-token-allocation-instruction-v1 | Pre-built typed Rust bindings for the Splice splice-api-token-allocation-instruction-v1 DAR. |
canton-splice-api-token-allocation-instruction-v2 | Pre-built typed Rust bindings for the Splice splice-api-token-allocation-instruction-v2 package (CIP-0112). |
canton-splice-api-token-allocation-request-v1 | Pre-built typed Rust bindings for the Splice splice-api-token-allocation-request-v1 DAR. |
canton-splice-api-token-allocation-request-v2 | Pre-built typed Rust bindings for the Splice splice-api-token-allocation-request-v2 package (CIP-0112). |
Splice bindings
| Crate | |
|---|---|
canton-splice-amulet | Pre-built typed Rust bindings for the Splice splice-amulet DAR. |
canton-splice-wallet | Pre-built typed Rust bindings for the Splice splice-wallet DAR. |
canton-splice-wallet-payments | Pre-built typed Rust bindings for the Splice splice-wallet-payments DAR. |
Generated protocol types
| Crate | |
|---|---|
canton-proto | Generated 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
| Toolchain | Status | Where it is checked |
|---|---|---|
| 1.88 (MSRV) | supported | the msrv CI job builds the whole workspace with it |
| stable | supported | every other CI job |
| beta / nightly | untested | — |
The MSRV is workspace.package.rust-version. Raising it is a minor version
bump, not a patch.
Platform
| Target | Status | Where it is checked |
|---|---|---|
Linux (ubuntu-latest) | supported | the test job’s matrix |
macOS (macos-latest) | supported | the test job’s matrix |
Windows (windows-latest) | supported | the test job’s matrix |
aarch64-unknown-linux-gnu | compiles | the cross-targets job (cargo check --workspace --all-features); no test run |
x86_64-unknown-linux-musl | compiles | the 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 API | v2 |
| Canton | 3.5.7 — the release the .proto files are vendored from |
| Verified against | a 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 CI | the 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
| Version | Status |
|---|---|
| LF 2.1 | supported |
| LF 2.2 | supported |
| LF 2.3 | supported 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 minors | refused, naming the minor and this build’s range |
| LF 1.x | refused, 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
| Version | Types | Workflow |
|---|---|---|
CIP-56 (-v1) | generated: holding, metadata, transfer instruction, allocation, allocation instruction, allocation request, burn/mint, featured app | canton-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 request | canton_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:
| Crate | Standard | What it holds |
|---|---|---|
canton-splice-api-token-metadata-v1 | V1 and V2 (shared) | ChoiceContext, ExtraArgs, Metadata, AnyValue |
canton-splice-api-token-holding-v1 | CIP-56 V1 | Holding, InstrumentId, Lock |
canton-splice-api-token-transfer-instruction-v1 | CIP-56 V1 | TransferFactory, TransferInstruction |
canton-splice-api-token-allocation-v1 | CIP-56 V1 | Allocation, SettlementInfo, TransferLeg |
canton-splice-api-token-allocation-instruction-v1 | CIP-56 V1 | AllocationFactory, AllocationInstruction |
canton-splice-api-token-allocation-request-v1 | CIP-56 V1 | AllocationRequest |
canton-splice-api-token-burn-mint-v1 | CIP-56 V1 | BurnMintFactory |
canton-splice-api-featured-app-v1 | V1 (featured-app API) | FeaturedAppRight, activity markers |
canton-splice-api-token-holding-v2 | CIP-0112 V2 | Holding, Account, InstrumentId, Lock |
canton-splice-api-token-transfer-instruction-v2 | CIP-0112 V2 | TransferFactory, TransferInstruction over accounts |
canton-splice-api-token-transfer-events-v2 | CIP-0112 V2 | EventLog, the transfer events events::holdings_changes reads |
canton-splice-api-token-allocation-v2 | CIP-0112 V2 | Allocation, SettlementFactory, SettlementInfo, TransferLegSide |
canton-splice-api-token-allocation-instruction-v2 | CIP-0112 V2 | AllocationFactory, AllocationInstruction |
canton-splice-api-token-allocation-request-v2 | CIP-0112 V2 | AllocationRequest |
canton-splice-amulet, canton-splice-wallet, canton-splice-wallet-payments | V1 and V2 — Amulet implements both | the Splice application packages, referencing the crates above rather than copying them |
canton-token | V1 (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.
| What | LocalNet (Canton 3.5.7) | DevNet (Canton 3.5.17) |
|---|---|---|
| V1 transfer, end to end | committed at offset 67684, 6 events, kind offer | committed at offset 3316298, kind direct — settled on submission |
V2 Account-based transfer | committed at offset 67690, 6 events | committed at offset 3316304, direct |
V2 event parsing (events::holdings_changes) on a committed transaction | one holdings change: 1 spent, 2 produced, 2 legs | two 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 created | allocated 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 participant | settled 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
| Scribe | 3.5.4 |
| Verified against | a 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
| Suite | CI | Needs |
|---|---|---|
| unit, in-process, TLS, WebSocket | yes | nothing |
| submit → observe → query, on gRPC and on JSON, as one flow | yes, 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) | yes | nothing |
| bindings drift, eighteen of the nineteen generated crates | yes | the 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 oracle | yes | a JVM |
| live Ledger API, interactive submission | yes, 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 unset | a Canton participant with its admin API reachable |
| live PQS | yes, 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 registry | yes, 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 type | Rust type | JSON (LF-JSON) | gRPC (Value) |
|---|---|---|---|
Unit | rt::Unit | {} (empty object) | Unit |
Bool | bool | true / false | Bool |
Int64 | rt::Int64 | string (number also accepted on input) | Int64 |
Numeric n | rt::Numeric | string (number also accepted on input) | Numeric (string) |
Text | String | string | Text |
Party | rt::Party | string | Party |
Timestamp | rt::Timestamp | RFC 3339 string | Timestamp (micros) |
Date | rt::Date | YYYY-MM-DD string | Date (days) |
ContractId t | rt::ContractId<T> | string | ContractId |
List t / [t] | Vec<T> | array | List |
Optional t | Option<T> | null / value | Optional |
Optional (Optional t) … | Option<rt::NestedOpt<T>> … | nested list form ([] / [x]) | Optional |
TextMap t | rt::TextMap<T> (BTreeMap<String, T>) | object | TextMap |
GenMap k v | rt::GenMap<K, V> | array of [k, v] pairs | GenMap |
type variable a | type parameter A (upper-camel) | — | — |
| reference to a named type | crate::<package>::<module>::<Type>, or ::<other_crate>::<package>::<module>::<Type> when the package is generated into a crate of its own | per that type | per that type |
| directly-recursive reference | Box<…> | transparent | transparent |
Notes on the subtle ones
-
Unitis the empty object{}in LF-JSON, not JSONnull— so it maps to a dedicatedrt::Unit, not Rust()(whose serde form isnull). -
Nested
Optional. The top-levelOptionalisnull/value; everyOptionaldirectly inside another uses the LF-JSON list form soSome None([]) stays distinct fromNone(null). Only a directly nested one:Optional [Optional t]isOption<Vec<Option<T>>>, because the list element boundary already separates the two. Codegen wraps each directly nested layer inrt::NestedOpt; the gRPC form is an ordinary protoOptional. -
Int64is LF-JSON-encoded as a string (encodeInt64AsString, to survive JavaScript’s 53-bit precision); it maps tort::Int64(a newtype overi64) which serialises to a string and accepts a string or a number on input, matching the Ledger API. Its gRPC form is a plainInt64. -
Numerictravels as a decimal string on both wires to avoid binary-float rounding; on JSON inputrt::Numericalso 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 everycanton-splice-*crate in this repository is built, so that aHoldingis 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
ExternalPackagesmap; 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 declaration | Generated Rust |
|---|---|
| record | struct 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 |
| variant | adjacently-tagged enum ({"tag": <ctor>, "value": <payload>}) + codecs; a nullary constructor carries rt::Unit so its wire form is {"tag":<c>,"value":{}} |
| enum | fieldless enum (also Copy); JSON form is the bare constructor-name string |
| template | payload 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 |
| interface | a marker struct + impl rt::Contract + impl rt::Interface (its view type) + a typed impl rt::Choice<Marker> per interface choice |
| contract key | type 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 onContractId<Template>andContractId<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 generatesmy_app_1_0_0andmy_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 inlower.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-samplecommits#quickstart-licensing:Licensing.AppInstall:AppInstallRequestand 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.1 | v0.0.2 | differing | |
|---|---|---|---|
| differing lines, whole file | 8 | ||
| …all of them | const PACKAGE_ID | ||
pub mod | 49 | 49 | 0 |
pub struct | 75 | 75 | 0 |
pub enum | 8 | 8 | 0 |
#[serde(rename …)] | 370 | 370 | 0 |
ENTITY_NAME / MODULE_NAME / PACKAGE_NAME | 8 / 8 / 8 | 8 / 8 / 8 | 0 |
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.
| ADR | Title | Status |
|---|---|---|
| 0001 | Vendored protos, pinned to a Canton release | Accepted |
| 0002 | Generated proto types are protocol-stable, not SemVer-stable | Accepted |
| 0003 | Shared connection kernel lives in canton-core | Accepted |
| 0004 | Returned streams are detached from the client lifetime (use<>) | Accepted |
| 0005 | All canton-* crates version in lockstep | Accepted |
| 0006 | Telemetry: the SDK emits, the application exports | Accepted |
| 0007 | A thin canton facade crate is the documented entry point | Accepted |
| 0008 | A native Rust Daml-LF decoder (not the JVM daml-lf-archive) | Accepted |
| 0009 | Daml-LF version-support policy for codegen | Accepted |
| 0010 | API changes deliberately deferred to 1.0 | Accepted |
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 buildworks 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.mdrecords what each subtree tracks and which release it is pinned to,proto/SHA256SUMSrecords a hash per file, andcanton-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.shre-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-checksis scoped to the hand-written surface. - Consumers needing insulation from wire types use the generated bindings for
payloads and
canton_ledger::updatefor the update envelope. The proto-typed update reads stay public until 1.0, when they move behind*_rawnames (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-codegenruntime,canton-token,canton-pqs) get the same foundation. canton-corecarries tonic’s transport+TLS stack; auth-only consumers pay that compile cost (acceptable; revisit with aconnectfeature 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
selfpast the first await — enforced by the compiler through theuse<>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: with0.2.0published, a consumer whose lockfile pairscanton-ledger 0.2.0withcanton-core 0.2.1resolves 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.zbetween 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 cantongives the whole SDK as one lockstep-versioned set (ADR-0005), with one place to enable features.- The
cantondownload 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:
-
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.protolive in that directory). Vendoring that authoritative schema and decoding it withprostis 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. -
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-
cargobuild-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-archivebackend 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.rsreads a DAR and generates types with no JVM, no subprocess — the workflow Rust users want. Consumers of pre-builtcanton-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-Versionin a DAR’s manifest (test corpus DAR is3.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 incanton-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-lfprovenance) 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:
| Group | Crates | Hand-written Rust, lines | What it does |
|---|---|---|---|
| Client | canton-core, canton-auth, canton-ledger, canton-admin, canton-signer, canton-pqs, canton | ≈ 10,500 | TLS 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 |
| Codegen | canton-lf, canton-codegen, canton-codegen-cli, canton-daml | ≈ 7,300 | the 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) |
| Token | canton-token | ≈ 2,100 | the 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 controls | The SDK must |
|---|---|
| Responses from a participant (gRPC or JSON): a compromised or impersonated node, a proxy | not 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 connection | bind 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 output | never 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
- 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. - Findings rated critical / high / medium / low / informational, each with a reproduction.
- Critical and high findings are remediated before the report is published; medium and low are remediated or accepted with a written rationale.
- 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:
ringandrustlsare 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
| Date | 2026-09-07 |
| Network | cn-quickstart LocalNet (Splice 0.6.11 images), SV_PROFILE=on |
| Participant | Canton 3.5.7, the App Provider node: gRPC :3901, JSON :3975 |
| Registry | the super-validator’s scan on :5012, reached through a socat forward |
| Instrument | Amulet (Canton Coin), which declares every V1 and V2 token-standard API |
| Sender | the App Provider party (app_provider_quickstart-…) |
| Receiver | the App User party (app_user_quickstart-…) |
| Executor (allocation only) | the super-validator party (sv::…) |
| Authentication | Keycloak 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(®istry, 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
| Clause | Evidence |
|---|---|
| V1 transfer settles end to end | v1_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 transfer | v2_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 allocation | v2_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 disclosure | every 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
| Date | 2026-09-22, 07:32–07:34 UTC |
| Network | Canton Network DevNet, synchronizer global-domain::1220be58… |
| Participant | nodejumper-dev-1, Canton 3.5.17, Splice validator 0.8.1 (Nodejumper’s node) |
| Ledger API | gRPC over TLS on :443; JSON Ledger API over HTTPS |
| Authentication | Keycloak, realm canton-devnet: a user token with actAs on the sender party and nothing else |
| Registry | the public DevNet Scan of SV-1, https://scan.sv-1.dev.global.canton.network.sync.global, unauthenticated |
| Instrument | Amulet (Canton Coin); the DevNet registry declares all ten V1 and V2 APIs |
| Sender | 8f2bb33b-699f-440e-a861-508ee6a0472d::1220a5cd…, a wallet user on that node, funded by a tap |
| Receiver and executor | nodejumper-dev-1::1220a5cd…, the validator’s own party |
| Amount | 1 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(®istry, 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 id | Offset | Record time | Events visible to the sender |
|---|---|---|---|
12203d9608d50269325b… | 3316298 | 2026-09-22T07:32:29.748296Z | 2 |
12202e8ddcd542ff100b… | 3316304 | 2026-09-22T07:32:34.873070Z | 2 |
12201e70b86ba0ef478f… | 3316319 | 2026-09-22T07:32:47.179492Z | 4 |
12209b5d69f163eca284… | 3316364 | 2026-09-22T07:33:42.266115Z | 3 |
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 id | Offset | Record time | Events visible to the sender |
|---|---|---|---|
1220424177cbc89173b0… | 3317129 | 2026-09-22T07:49:20.440389Z | 4 |
1220f4a77863cb1ad1ea… | 3317173 | 2026-09-22T07:50:15.185034Z | 3 |
1220a959f224d586e432… | 3317197 | 2026-09-22T07:50:35.177075Z | 1 |
1220b829f7b412aedd52… | 3317221 | 2026-09-22T07:50:57.874686Z | 1 |
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-tokenlive 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-ledgerlive 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
| Clause | Evidence |
|---|---|
| A V1 transfer settles end to end on DevNet | committed 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 DevNet | transfer 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 settlement | settle_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 registry | six 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.
| LocalNet | Canton Builder Tool, Splice 0.6.11 images, Canton 3.5.7; auth self-signed (HS256 tokens from canton builder token, whose user holds ParticipantAdmin) |
| Registry | the LocalNet’s own Scan, http://scan.localhost:4000 |
| Party | app_provider_builder-localnet-1::122050a3…, sender, receiver and executor at once (kind: self) |
| External signing | the built-in Ping template every participant ships, created by the freshly onboarded external party |
| PQS | Scribe 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(®istry, 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(®istry, 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(®istry, 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(®istry, 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 / example | Result |
|---|---|
canton-ledger live | 36 passed |
interactive_live (external signing) | 3 passed |
canton-token registry live | 5 passed |
canton-pqs live, Scribe 3.5.4 over TLS following the participant from its latest offset | 9 passed, store ready at offset 3874307 |
submit_and_read, recover_a_submission, canton-sample | committed; the duplicate refused as DUPLICATE_COMMAND and the original outcome recovered; the typed loop on both transports |
json_packages / fetch_packages | 290 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.
| Example | Update id | Offset | Record time |
|---|---|---|---|
v1_transfer | 1220ebc554e79602ca58b5cede1a60bf28a05eead6ee625c4e5590b576a1e0873b6d | 3874147 | 2026-09-29T10:09:00.343272Z |
v2_transfer | 12203fbcf8faeeb8113006e9575dd1ad9d8c7d2dc50f4b06acb2778eb76f186233d5 | 3874160 | 2026-09-29T10:09:05.583538Z |
v2_allocate | 122066fb28a402e4d80fcd1d5cad14221fb871fe33fcea38b55de6d9557219e8b6e2 | 3874169 | 2026-09-29T10:09:11.593049Z |
v2_withdraw_allocation | 1220d6cddeee588d4a5573fb8caf152397a0168f94bc482e190d3e327cabb6bba22b | 3874175 | 2026-09-29T10:09:17.353045Z |
v2_allocate | 122077f9e709bb1fa02111f62b75dfd3a4ae108de95d999dd8942dee89b9db923eff | 3874181 | 2026-09-29T10:09:22.243755Z |
v2_settle | 12209cdb5421ab0a1791651d6a637726abc5e1505caef97fc1edf94a0311aad24610 | 3874198 | 2026-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.
| LocalNet | Canton Builder Tool (Canton 3.5.7) with the App User validator added (canton builder start --with app-user): three participants on one synchronizer |
| Registry | the LocalNet’s own Scan, http://scan.localhost:4000; instrument Amulet, amount 1.0 |
| Code | main plus the receiver’s mode of v2_allocate (CANTON_TOKEN_SIDE=receiver); the SDK library is unchanged since v0.3.1 |
| Role | Party | Participant | Step |
|---|---|---|---|
| Sender | app_provider_builder-localnet-1::122050a3… | app-provider, :3901 | v2_allocate: the sender’s side, one holding reserved |
| Receiver | app_user_builder-localnet-1::12201187… | app-user, :2901 | v2_allocate with CANTON_TOKEN_SIDE=receiver and the sender’s settlement id: the receiver’s side |
| Executor | sv::1220e8c9… | sv, :4901 | v2_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(®istry, 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(®istry, 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.
| Update | Read as | Participant | Offset | Record time |
|---|---|---|---|---|
sender’s allocation 1220973efa56a4f1abaa… | sender | app-provider | 13781 | 2026-10-13T11:21:09.453125Z |
receiver’s allocation 1220e7606e818594c8af… | receiver | app-user | 181 | 2026-10-13T11:21:11.201934Z |
settlement 122062792e2766b8b39d… | sender | app-provider | 13784 | 2026-10-13T11:21:13.592559Z |
settlement 122062792e2766b8b39d… | receiver | app-user | 184 | 2026-10-13T11:21:13.592559Z |
settlement 122062792e2766b8b39d… | executor | sv | 23299 | 2026-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.
- Security issues, reported as SECURITY.md describes. Security fixes ship as patch releases of the current minor.
- 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.
- 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.
- 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
semverjob runscargo semver-checksagainst the published baseline for the crates it lists. Generated protobuf types are exempt, per the stability policy incanton-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 warningson 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-checkson 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
- Bugs, questions and feature requests: GitHub issues.
- Vulnerabilities: privately, as SECURITY.md describes.
- Contributions: CONTRIBUTING.md.
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
protocneeded — the build vendors one. - Optional, for live integration tests: a local Canton participant via
cn-quickstartLocalNet.
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# Errorson public functions. - SemVer: public types are
#[non_exhaustive]where growth is expected; builder methods usewith_*/add_*naming. Generated proto types are exempt (see the stability policy incanton-proto). - Keep commits focused; update
CHANGELOG.mdunder[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 repointv4at 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:
canton-corecanton-lfcanton-protocanton-authcanton-codegencanton-signercanton-admincanton-codegen-clicanton-damlcanton-daml-stdlibcanton-ledgercanton-splice-api-featured-app-v1canton-splice-api-token-metadata-v1canton-splice-api-token-holding-v1canton-splice-api-token-holding-v2canton-splice-api-token-allocation-v1canton-splice-api-token-allocation-v2canton-splice-api-token-burn-mint-v1canton-splice-api-token-transfer-events-v2canton-splice-api-token-transfer-instruction-v1canton-splice-api-token-transfer-instruction-v2canton-splice-api-token-allocation-instruction-v1canton-splice-api-token-allocation-instruction-v2canton-splice-api-token-allocation-request-v1canton-splice-api-token-allocation-request-v2canton-splice-amuletcanton-tokencanton-pqscanton-splice-wallet-paymentscantoncanton-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 --checkcargo clippy --workspace --all-targets --all-features -- -D warningscargo test --workspace --all-featuresRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-featurescargo deny check allcargo hack clippy --workspace --feature-powerset --no-dev-deps -- -D warningscargo semver-checksover every crate with a published baseline — the list CI’ssemverjob 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, thecanton-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 liveand the same for-p canton-admin - The reference app end to end on both transports:
cargo run -p canton-sample CHANGELOG.md: turn— unreleasedinto the dateREADME.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.mdbook/src, the docs site’s own pages: every place that names a version, such as the--versionin the codegen guide’scargo installlines:git grep -nE "0\.[0-9]+\.[0-9]+" -- book/src(reference/andproject/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_sizealso caps HTTP response bodies;- PQS
Predicate::eqcompares the stored text, andcomparewithOp::Eqdoes no numeric cast (only the ordered operators do), so numeric equality across scales needsOp::GeplusOp::Le; - the compatibility matrix’s live-test counts and its LocalNet settlement row were stale.
- turning off the facade’s default features drops the in-memory key type,
not
[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_eventandfrom_json_created_eventnow 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 sameModule:Entitywith 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 needpackage_nameset. - 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_v3andhash_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_withforms taking anUpdatesRequest’s format) andlatest_pruned_offsetsonCantonClientandJsonClient. “No such update” isOk(None).Error::pruned_earliest_offsetnames 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_withandJsonClient::updates_typed_withreturn 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 thecommands__contract_keysconformance row generates from it. v2_allocateruns 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-lf0.3.x does not build on a fresh install. zip 2.6, inside the2range the crate accepts, added a type parameter toZipFile, whichcanton-lfnamed; the workspace lockfile held zip at 2.4, so CI stayed green whilecargo add canton-lf,cargo add canton-codegenandcargo install canton-codegen-cli(without--locked) failed to compile. The entry is now read throughstd::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-cliinstalls 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, whichcanton-lfdoes not call: it reads each DAR entry into memory under a size cap. Moving to zip 9 changes the error typecanton_lf::DarError::Zipcarries, so it waits for 0.4;deny.tomlrecords the three with that reason. - The
PreparedandPrepare::with_hashing_scheme_versiondocs 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 newcanton_ledger::hashingmodule rebuilds the hash from the returned transaction and metadata, byte for byte as Canton does (checked against Canton’s own test vectors), andprepare_submissionrefuses 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_partyrefuses 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 aContent-Length. canton-core: a server-recommended retry delay is capped atMAX_SERVER_DELAY(60 s, logged when capped), a spelledretryInfois clamped after parsing, and the jitter arithmetic can no longer panic on a hostile value. ARetryInfodetail 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 onDUPLICATE_COMMANDorALREADY_EXISTS, not on any HTTP 409 (which isABORTEDas often as not on Canton’s JSON lane) or on a body that merely mentions the word. Two backpressure refusals no longer come back asOk.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 thatcargo testwould run) in the generated crate.
Added — Daml-LF 2.3
canton-lfdecodes LF 2.3, the version Canton 3.5.x SDKs emit and a DevNet participant already vets (Digital Asset’s utility apps,daml-stdlib3.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 typespb::daml_lf_devis nowpb::daml_lfand aTypereferencing an interned type spells itInternedType, following the schema.canton_lf::pbis now#[doc(hidden)]: generated schema types, exempt from SemVer ascanton-proto’s are (ADR-0002), reachable but not part of the documented surface; CI’s semver guard skipscanton-lffor this one release and takes it back with 0.3.1 as the baseline.fetch_packagesskips a package in an LF version the build does not read and continues, instead of stopping at it.
Changed
Prepareasks forHASHING_SCHEME_VERSION_V2explicitly rather than leaving the choice to the participant.canton-proto’ssmoke_versionexample speaks TLS to anhttps://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_STARTis the hint). tools/pqs/wait-ready.shwaits for Scribe’s ingestion to settle, not only for its schema to exist.docs/security/audit-scope.mdnames 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-v1andcanton-splice-api-featured-app-v1.canton-splice-amulet,-wallet,-wallet-paymentsandcanton-quickstart-licensingreference 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 throughcanton-splice-amuletdid not typecheck against theHoldingofcanton-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::…becomescanton_splice_api_token_holding_v1::splice_api_token_holding_v1::…, and the new crate is added toCargo.toml. Nothing changes on the ledger: the package ids, template ids and wire encodings are identical. splice-utilis unchanged — it ships no DAR of its own, so it stays insidecanton-splice-amulet, andcanton-splice-walletreaches it from there.canton-daml-stdlib(new) owns the Daml standard library —daml-stdlib,daml-prim,ghc-stdliband their per-module packages — and every bindings crate references it. The same defect applied to those:RelTimeappears 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-v1134 KB → 12 KB,canton-splice-wallet310 KB → 189 KB,canton-splice-amulet556 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-pqsdid not require TLS fromconnect_tls. The connection string went to the driver unchanged, and tokio-postgres’s default issslmode=prefer— so a store with TLS off, or a string carryingsslmode=disable, gave a plaintext session from a method namedconnect_tls, with no error. The mode isRequirenow, and a test proves a store that answers the TLS request with “no” is refused.canton-pqscalled 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 onis_retriable()spun on its own mistake. The driver’s permanent kinds are classifiedInvalidRequest; and the client, which held one connection and could only ever say “retriable” once the store had closed it, now reconnects.canton-pqshad no deadlines. Connect and query are bounded (30s by default;connect_with_timeout,with_timeout) and surface asError::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 alimitwithout 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.
Instrumentwas a transcription of metadata-v1 1.0.0. The 1.2.0 document addspaused,pauseInfoandaccountInputFieldsToShow; a wallet on this crate could not say why a paused instrument refused. Added. The seven OpenAPI documents are vendored undertestdata/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’sHoldingviews from the ACS and says which are spendable; the examples use it whenCANTON_TOKEN_HOLDINGSis unset, and all three commit first try.execute_submissiondid not recognise its own retry. A retried attempt the participant de-duplicated came back asALREADY_EXISTSand was reported as failure — the casesubmit_commandsalready handles. Handled the same way;Prepared::change_id/Executable::change_idhand out whatawait_completionneeds;execute_submission_and_waitreturns the whole response, completion offset included, rather than the update id alone.- Three live suites skipped silently.
canton-token,canton-pqsandinteractive_liveneither said so nor looked atCANTON_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/partiesin the JSON client; the row is a declared gap with the reason. (packages__json_package_mgmtwent the other way — see reading packages.) - docs.rs metadata was missing on every M3 crate, so
connect_tlsand the in-memory key would not have been documented, and nothing after publication can fix that. Every hand-written crate carries the block now, withdoc(cfg)on the gated items. Generated crates forbidunsafe_codein 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
(
ed25519andpqs-tlswere 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-tokenworkflow function — V1 allocation, the instruction choices, all ofv2::*— 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-processStateService; 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’sDebugand 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. rustls0.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 withcargo 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’sAppInstallRequest, andtools/pqs/compose.yamlruns 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_batchincluded. - 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 asdirect, the allocation was created and then withdrawn by the sender —v2_withdraw_allocationis 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, sov2_allocatenow authorises both when the sender is also the receiver. The token examples take aCANTON_TOKENbearer token as well as OIDC credentials, and an OIDCCANTON_TEST_AUDIENCE. canton-ledger’s tests did not build on their own: the dev-dependency oncanton-signerinheriteddefault-features = falseand 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.Ed25519Keyis the in-memory implementation, onring, behind a defaulted25519feature an HSM implementer can turn off.- A key is not yet an identity: Canton addresses a key by a fingerprint it
computes itself, so
Ed25519Keycannot sign for the ledger untilinto_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, andunsigned_partiesanswers “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_topologyandallocate_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
cantonfacade re-exports the three new crates ascanton::signer,canton::tokenandcanton::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:RegistryClientfor 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
choiceArgumentsas the choice “encoded using the Daml JSON API, withextraArgs.contextandextraArgs.metaset to the empty object”, and returns the context the same way — which is whatcanton-damlimplements. So a generated choice serializes straight onto the wire and the reply deserializes straight into the generatedChoiceContext. TokenCommandkeeps 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::transferandtoken::v2::transferare 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.tomlis 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
cantonfacade, which is how it found thatcanton::telemetrywas not re-exported — the metric names and transport labels an application builds a dashboard from were unreachable fromcargo 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
conformanceCI 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-pqsclassified every Postgres SQLSTATE asInvalidRequest, 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,55and57are transient, everything else is the caller’s.classifyhad no test at all; it has three.canton-token’s registry client turned every transport failure into a retriableError::Connectioncarrying 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_packagecompared the participant’s ownhashfield 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-, sofoo.lfpayloadyielded the package idfooand 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 —
JsonClientcovers 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 thanidlines — gaps carry anidtoo.
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, theAccountmodel and theactorsfield have met a real registry rather than a transcription of its specification. - V2 event parsing on a committed transaction —
events::holdings_changesread 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 onCANTON_TOKEN_REGISTRY_URL) covers the registry read path: the admin party parses, instruments decode with the optional fields genuinely absent, a not-issued instrument isNonerather 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_HOLDINGSwas 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-tlsreached 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’shashfield.
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 theAccountmodel),-transfer-instruction-v2,-transfer-events-v2(theEventLoga V2 transfer is parsed from),-allocation-v2(withSettlementFactory,Allocation_SettleandFinalizedAllocation— executor settlement),-allocation-instruction-v2and-allocation-request-v2. - V2 reuses
splice-api-token-metadata-v1: there is nometadata-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
GetPackageand committed undertestdata/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-stdliband the metadata crate rather than copying them, exactly as the V1 crates do — theghc-stdlibanddaml-stdlibpackages 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 V1execute-transfercontext 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 pluralallocations. 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_changesreads what actually moved off a transaction. A V2 registry records it by exercisingEventLog_HoldingsChangeon theEventLoginterface, 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.rsis the V1 example’s counterpart, written to be read beside it, andexamples/v2_allocate.rsis 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 namesSettlementInfo,AllocationSpecification,TransferLegSideandAccountwith no direct dependency on anycanton-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) andJsonClient::package_status(GET /v2/packages/{package-id}/status), returning the gRPC path’sPackageStatusso 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 throughAllocateParty),update_party_details(PATCH /v2/parties/{party}with a field mask) andparticipant_id. All answer in the gRPC path’sadmin::PartyDetails, so a caller switches transports without re-learning the type. Allocation and update are not retried, for the reasoncanton-admingives. 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_packagedownloads a package’sArchivePayloadbytes and checks the hash the participant returns against the id that was asked for. Withlist_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_payloadreads that shape. A DAR entry is a wholeArchive(payload, hash and hash function together) anddecode_packagereads that; the Ledger API returns the three as separate fields. Mistaking one for the other fails with a protobuf error about ahashfield that says nothing about the cause. -
canton-codegen—lower_packages_selectinggenerates 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 isPACKAGE_NAME:MODULE_NAME:ENTITY_NAME, read off the generatedContract, 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-damlimplements, 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
Int64andNumericas strings, so comparing lexically would sort"9"after"10". tlsfeature forconnect_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
Selectionandlower_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_prefixedandExternalPackages::with_prefixedtake 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-primdoes not also match adaml-primary.
Publish order — this replaces 0.2.0’s, which no longer works.
canton-ledgerandcanton-adminnow depend oncanton-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-sampleandcanton-quickstart-licensingstay unpublished.
Changed — CI
- The packaging check derives its crate list from
cargo metadatarather 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-stdlibis 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
Statuswith codeUnknown(message “transport error” / “h2 protocol error”), not as atransport::Error— that variant is only for connection establishment. SoError::is_retriable()treated the most ordinary failure there is as terminal:updates_resumablegave up on the first blip andrun_with_retrynever 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-lessUnknown/Internalstatus whose source chain is populated is now retriable; a bare serverUnknown(decoded from trailers, no source) stays terminal. Found by killing a real TCP connection under load through a chaos proxy — in-process mocks had emittedStatus::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_eventrejected theevents-by-contract-idresponse 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 testwithout--all-features.futures_are_spawnableassertedJsonSubmission::recoverisSend, but that method isws-gated while the assertion was not — so the default-feature build failed to compile. Invisible because CI ran tests only with--all-features; a secondcargo test --workspacejob (default features) now guards the whole class. Found by running the suite under every feature combination. -
tokio-stream’s declared lower bound was0.1, but thenetfeature it uses landed in0.1.1— a minimal-versions resolve does not build against0.1.0. Corrected to0.1.1. Found bycargo +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
*ParseErrorrenames and the removal ofParty’s unvalidatedFrom<&str>/From<String>to 1.0, the latter blocked from gaining a checkedTryFromtoday by std’s blanket impl).ChangeIdderivesHash— it is the key the docs tell applications to track submissions by, and it could not be aHashMapkey.Store,TransactionShapeandClientAuthfollow.canton-ledgerandcanton-adminre-exportAuth,ErrorInfoandResourceInfo(and admin gainsErrorCategory): these appear in the signatures ofError::error_info/resource_infoandConfig::auth, so a user of one client crate could not previously name the types their own functions return without adding acanton-coredependency.ErrorCategoryimplementsTryFrom<i32>andFrom<ErrorCategory> for i32alongside the inherent methods.#[must_use]on the constructors that lacked it; docs.rs feature badges on all seven remainingws-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 betokio::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
TopologyClientlist 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, andSubmission::submit_and_wait. Hand-written code measures 87.7% line coverage;cargo udepsreports 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_COMMANDtheis_duplicate_submissionpredicate 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-ledgerdocumented 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 docnever setsdocsrs, 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
docsrsCI 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.tomlpins 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, andrust-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 setRUSTUP_TOOLCHAIN, which is the override that beats the file. - The
cantonfacade declaredall-featuresfor docs.rs without the matchingrustdoc-args, the same inconsistency one step behind. cargo-semver-checkscovered 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-protostays out by ADR-0002 andcanton-codegen-clihas no library API.
Changed
RELEASING.mdcarries the post-publish verification the pre-publish gate cannot do:cargo packageruns--no-verifybecause 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 anaudienceby 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);thensubmission.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 (vendoreddaml_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 (asrt): the Daml primitive types (Party,ContractId<T>,Numeric,Timestamp,Date,TextMap,GenMap,NestedOpt,Unit), theToValue/FromValuegRPC codecs and serde JSON conventions (LF-JSON:Int64andNumericas strings — accepting a number on input — the nested-optional list form,Unitas{}, adjacently-tagged variants, …), theContract/Template/Interface/WithKey/Choicetraits, and thecreate_command/exercise_command/exercise_by_key_commandbuilders.canton-codegen— DAR → typed Rust: a decoder-agnostic IR, the documented Daml-LF → Rust type mapping (seedocs/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-qualifiedpub modtree (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— thedpm-codegen-rustbinary (adpm codegen-rustcomponent / 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, andcanton-quickstart-licensing. canton-sample— reference app: builds a typedAppInstallRequestfrom 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::localnetreads a Splice LocalNet out of the environment — the variables canton-devkit’slocalnet envexports. Aftereval "$(canton-devkit localnet env demo)",Config::from_env()is a working gRPC configuration andJsonClient::from_env()its JSON counterpart, with the participant’s token attached;Config::from_env_for("app-user")reaches the other participant, andlocalnet::party(alias)the on-ledger id an application needs foract_as.CANTON_ENDPOINT/CANTON_TOKENoverride the lot for an environment that is not a LocalNet. Runnable as thelocalnetexample.Configaccepts a scheme-lesshost:port, which is what a gRPC client dials and therefore what tooling hands out —CANTON_GRPC_LEDGER_API_URLis 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.dalfis 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.TlsConfigderivedDebugand holdsclient_identity_pem, soformat!("{config:?}")— or onetracingfield capturing aConfig— printed the key byte by byte;Configleaked it too, along with any credentials in the endpoint URL. Both now have hand-writtenDebugthat reports presence and length. Five error paths acrosscanton-core,canton-authandcanton-ledgerthat quoted a URL verbatim now run it throughcanton_core::redact_url. Two more types held the same secret behind aDebugand were missed the first time:JsonClientderived one and printed itsbase_url— userinfo included — andOidcConfigredacted itsclient_secretfield while printing atoken_urlthat, for a provider taking client credentials as basic auth, is the secret.TokenResponseno longer derivesDebugat 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’sDebug, 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 inDebug):AuthInterceptorderivedDebugover 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, andSECURITY.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 toDuration::from_secs_f64, which panics on anything aDurationcannot 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::submissionandJsonClient::submissionfix the identity first and hand back aSubmissioncarrying itsChangeId, withrecoverreading the completion back. The existing client methods are thin wrappers over the same object. -
Recovery matches the whole change ID.
await_completioncomparedcommand_idalone; Canton identifies a command by (user, acting parties, command id), and two applications on one participant may each usedaily-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_withdroppedOffsetCheckpointframes 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 throughJsonClient::submission. All four exist in Canton 3.5.7’s JSON API; this was the SDK stopping short. -
JsonClient::ws_active_contracts_resumableresubscribes from the laststreamContinuationTokenrather than restarting the snapshot, which the gRPC lane has done since M1. -
The typed ACS read is lossless. Every gRPC ACS method matched
ActiveContractand dropped the rest, so a reassignment in flight at the snapshot offset —IncompleteUnassigned/IncompleteAssigned— vanished from a multi-synchronizer application’s view.AcsEntryand theacs_page/acs_entries/acs_entries_resumablefamily are the lossless read; the active-only methods keep their names and are now that read withinto_activeapplied. -
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) —auth0now takes it, which is a breaking signature change. Okta reads the credentials from anAuthorization: Basicheader and rejects them in the body asinvalid_client; the preset selects that, andClientAuthexposes the choice for custom endpoints. -
Telemetry covers a stream’s life, not its opening.
instrument_streamcounts 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 carriestraceparent(the only request a WS stream makes), structured events carrytrace_id, andotel::otlp_metricsis 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.submitwas 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.rswalks 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 asDUPLICATE_COMMAND, and recover the original outcome. Verified live — the recoveredupdate_idis 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::telemetrywas not re-exported, socargo add cantoncould not see the metric names, the transport labels, or — now — the OTLP setup those metrics are meant to be exported through. Theotelfeature also reachedcanton-coreonly by way ofcanton-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_asreaches the transaction filter of a submission’s response, matching the Ledger API’s own default; filtering toact_asalone 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_endand submissions. - Jitter never brings a retry back before a server-recommended delay; a
RetryInfois 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
RetryConfiginstead of a hardcoded five-at-250ms. list_known_partiesfails 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
.protofiles carry a provenance record and per-file SHA-256s, verified by a test, withtools/vendor-protos.shfor the refresh. CANTON_TEST_REQUIRE_LIVE=1turns 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-gnuandx86_64-unknown-linux-musl, and thecargo-semver-checksjob 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-admindocuments 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 viaConfig::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-sideOUT_OF_RANGEthat reads like a server fault. Applied at all 24 service-client constructions acrosscanton-ledgerandcanton-adminthrough 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_sizenow 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 —reqwestputs no limit on a response body.)canton-core(errors):Error::resource_info()exposes thegoogle.rpc.ResourceInfoCanton attaches to failures where “which one?” is the first question —CONTRACT_NOT_FOUNDnames the contract id. AVecrather than anOption, because the JSON transport carries a list.canton-ledger(unbounded waits): the JSON lane had no timeout at all.reqwestapplies 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_timeoutsets 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()andError::code()now answer on the JSON transport too. Both returnedNonethere while returning the real thing over gRPC — so an application that classified errors by error id, exactly aserror_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/contextandgrpcCodeValue); 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 — whileParty::newstill takes a wire value as-is, the same asymmetryNumericalready had.FromStrwasInfallible, so"".parse::<Party>()succeeded and the failure surfaced only at the participant, as aPermissionDeniednaming nothing. An empty party id is the shapestd::env::varreturns forPARTY=, 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 — inToValueand inTemplate::to_record— and they are different paths at runtime:create_commandsubmitsto_record, while a contract read back arrives throughfrom_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 goesto_value→from_value, which is the half the submit path does not use.canton-daml(decode errors in containers): a failure inside aList,TextMaporGenMapnow 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, aTextMapthe key (which locates an entry better than a position), aGenMapthe 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_fieldandrecord_valueare 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-licensingstays 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 requiredT: ToValuefor 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 aContractId. 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 throughRecord::new,Template::newand 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 onCODEGEN_COMPILE_TEST, which nothing set — so it skipped on every push and went stale when the runtime madeNumericandGenMap’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 generatedCargo.toml. It was interpolated raw, so a version of the form0.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_eventis the JSON counterpart offrom_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 intoevent["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), orlower_dar_withfor the library path. A DAR’s dependency closure is shared, sosplice-api-token-holding-v1sits under amulet, wallet and wallet-payments alike; generating it into each gave each crate its ownHolding, and Rust treats those as unrelated types. A program depending on two of the published binding crates failed to compile with “expectedHolding, found a differentHolding” — 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, sointerned_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 ahash_functionother than SHA-256 is refused rather than assumed. That id is embedded in generated bindings asPACKAGE_IDand 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. Accepts2.1and2.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): generatedFromValuebodies attach the field name to a failure, so a mismatch inside a nested record reportsmeta.valuesinstead of a bare “expected Text”.ValueError::atexisted and was documented as being used by generated code; it was not.
Fixed — pre-release hardening (M2 quality audit)
- Recursion is now Rust-sound.
Optionalself-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
FromValuelocates fields by label or declaration index (non-verbose output omits labels) and decodes an absent trailingOptionalfield asNone(record normalization under Smart Contract Upgrade drops them). Numericcompares numerically, not textually — the ledger echoes"1.5"as"1.5000000000"and the two are now equal (Eq/Ord/Hashon the canonical decimal;Numeric::parsevalidates early).- Typed read path:
Template::from_created_eventdecodes aCreatedEventinto the payload struct (with a template-identity check);ContractId::retagre-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 (
--forceto override); validates the crate name; the generated crate’s version is the DAR package’s version;--runtime-pathis absolutized and TOML-escaped. - Always-on fixture tests:
testdata/splice-api-token-holding-v1-1.0.0.darruns the full decode → lower → emit pipeline (plus determinism and CLI contract checks) in everycargo test, no external setup.
Changed
canton-coregainsError::Payload— the one addition to the surface published in 0.1.x. It carries acanton-damlcodec failure as itssource, so a typed decode and a transport failure land in the sameResultand an application can stay oncanton::Errorend to end.Erroris#[non_exhaustive], so the new variant does not break amatch.canton-damlsupplies the bridge (impl From<ValueError> for canton_core::Error), andValueErroris 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
cantonfacade re-exports the codegen runtime as [canton::daml], socargo add cantongets 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 oncanton-codegenfrom a build script). - The codegen pipeline (
generate,Options,Runtime,Stats) lives incanton-codegen, not in the-clicrate, so a build script can call it without pulling in a CLI.canton-codegen-cliis binary-only. - Codegen errors are typed:
GenerateErrorandCodegenErrorreplaceBox<dyn Error>andsyn::Error, andSkippedType(withmodule()/reason()) replaces a bare string. Nosyn/proc-macro2type appears in a public signature. Optionsuses the same consuming-builder style as M1’sConfig, andOptions/Stats/Runtimeare#[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→ thecanton-splice-*bindings.canton-sampleandcanton-quickstart-licensingstay 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_healthandsubmit_and_readread the sameCANTON_TEST_*variables as the live tests (the unprefixedCANTON_*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 onset 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) andCANTON_TEST_SYNC_IDwere 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’scanton builder deployuploads a DAR, which is how to getCANTON_TEST_LICENSING_PKGwithout cn-quickstart.
[0.1.3] - 2026-08-04
Fixed
canton-ledger:CompletionsRequest’s JSON body builder is now gated on thewsfeature, 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 withoutws, which failed the workspace’s own-D warningson the feature powerset. Consumers of 0.1.2 are unaffected: Cargo caps lints in registry dependencies, so adefault-features = falsebuild 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, andCompletionsRequest(modulerequest, 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, acceptingpackage:Module:Entityids with#package-namereferences) 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) — thefilters_for_any_party/filtersForAnyPartyfull-ledger ingestion form, for tokens with wildcard read authorization;CompletionsRequest::with_user_idto scope the completion stream to a submitting user id (pairs withSubmit::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. - bounded streams (
-
canton-ledger(submit):Submit::with_transaction_shapeselects the shape of the transaction returned bysubmit_and_wait_for_transaction(ledger effects — the default, as before — or ACS delta), removing the last hard-codedTransactionShape. -
canton-ledger(submit, fullCommandssurface): every remainingCommandsfield is now settable on both transports —Submit/JsonCommandsgainwith_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), andwith_min_ledger_time_abs/with_min_ledger_time_rel;Submitadditionally gainswith_prefetch_contract_keysandwith_taps_max_passes, andJsonCommandsgainswith_deduplication_period(raw JSON, mirroringSubmit::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 (fromErrorInfo.metadata["category"]on gRPC statuses, or theerrorCategoryfield of JSON API error bodies) with the retryability the error-code docs define; surfaced asError::category().Error::retry_delay()— the server-recommended pause, from thegoogle.rpc.RetryInfodetail (gRPC) or theretryInfofield (JSON).Error::correlation_id()— from thegoogle.rpc.RequestInfodetail (gRPC) orcorrelationId/traceId(JSON), for quoting to the participant operator.Error::is_retriableis now category-first for bothError::StatusandError::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” onABORTEDis not), a bareRetryInfocounts 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 413JSON_API_MAXIMUM_LIST_ELEMENTS_NUMBER_EXCEEDED(category 2 with a 1-secondretryInfo), even though shrinking the requestlimitis 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 onError::Http { status: 413, .. }or handle it before retrying.retry::run_with_retryhonours the server’sretry_delaywhen it exceeds the local backoff.
Fixed
canton-ledger(WebSocket error frames): aJsCantonErrorframe on a WS stream is now surfaced asError::Http(with the gRPC code’s canonical HTTP status and the full error body), instead ofError::CommandRejected. The frame is the same error object the JSON API returns in HTTP error bodies, so it now carries itscategory()/retry_delay()/correlation_id()— and, behaviour change,ws_updates_resumablenow correctly reconnects on transient participant errors (categories 1/2/…), which the never-retriableCommandRejectedmapping 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_tlsno longer connects in plaintext on anhttp://endpoint.tonicselects the TLS handshake from the URI scheme, not from the presence of atls_config, so TLS was silently ignored (no encryption, no server-certificate verification, no client certificate for mutual TLS). The scheme is now normalised tohttps://when TLS is configured, case-insensitively.canton-ledger(JSON and WebSocket TLS): the same normalisation onJsonClient::with_tls. Anhttp://base URL previously sent plaintext HTTP with the configured certificates unused and opened aws://socket; the base URL is now upgraded tohttps://so both the HTTP and WebSocket lanes use TLS.canton-ledger(JSON updates): the JSON update stream now requestsincludeReassignments, 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 absurdexpires_incan no longer overflow the cache deadline and panic.canton-core(retry):Error::is_retriablenow treats the whole5xxrange as retriable (plus408and429), instead of a hand-picked subset that missed codes such as501,507,509,511, and520.
[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 sharedConfig/Error/TLS/retry types at the root, and thews/otelfeatures forwarded (ADR-0007). -
canton-ledger— async Ledger API v2 client over gRPC:- Command submission: fire-and-forget
submit,submit_and_wait(update id), andsubmit_and_wait_for_transaction; change-ID de-duplication; completion stream +await_completionrecovery; theSubmitbuilder (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
wsfeature — WebSocket streaming (updates, ACS, completions, resumable updates), TLS-aware. - TLS/mTLS on gRPC, HTTP, and WebSocket from one shared
TlsConfig.
- Command submission: fire-and-forget
-
canton-core— shared kernel: typedErrorwith retriable/non-retriable classification and structuredgoogle.rpc.ErrorInfodetails;Config(endpoint, auth, TLS, timeout); opt-in retry with per-attempt timeouts and jittered exponential backoff; telemetry (tracingspans +metricscounters per method/transport, optional OTLP export + W3C trace-context propagation behindotel). -
canton-auth— OIDC client-credentialsTokenProviderwith 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).