A production-grade, async Rust SDK for the Canton Network Ledger API — the Rust member of Canton's language-binding set, funded by Canton dev-fund proposal #407. Apache-2.0.
Built on tonic/prost/tokio. Talks the Ledger API v2 over gRPC (primary) and JSON (HTTP + WebSocket), with correct change-ID de-duplication, command recovery, resilient/resumable streaming, TLS/mTLS on every transport, JWT/OIDC auth, and built-in telemetry.
Status: the Ledger API client and the type-safe DAR codegen are both released on crates.io (0.2.x). Everything here is verified against a Canton 3.5.7 participant: hermetic tests plus a live suite (submit, streaming, recovery, TLS/mTLS, auth), and an end-to-end typed loop — generate bindings from a DAR, submit a typed create, read it back, exercise a choice — over gRPC and JSON. CI holds the whole workspace to
-D warningson every feature combination. Token-standard support (CIP-56 / CIP-0112), a PQS client, and external signing are next.
| Crate | What it is |
|---|---|
canton |
The SDK entry point: a thin facade re-exporting the whole family (canton::ledger, canton::auth, canton::admin, canton::daml + the shared Config/Error at the root) with the ws/otel features forwarded. cargo add canton gets everything below as one version-locked set. |
canton-core |
Shared foundation: the Error/Result model (retriable classification, structured ErrorInfo details), the connection kernel (Config, Auth/TokenSource, TlsConfig, jittered retry with per-attempt timeouts), and telemetry (tracing spans + metrics, optional OTLP via otel). |
canton-proto |
Generated gRPC types + client stubs from vendored protos (Ledger API v2, Canton admin API topology read, gRPC health), pinned to a Canton release. Internal. |
canton-auth |
JWT/OIDC authentication: client-credentials TokenProvider with caching + refresh + bounded fetch, and Keycloak/Auth0/Okta presets that each produce their provider's normal token request (Auth0's audience, Okta's HTTP Basic credentials). |
canton-ledger |
The async Ledger API client, with the same operations on both transports. gRPC: submit / submitAndWait / submitAndWaitForTransaction, completions and change-ID recovery, ACS/update streaming (+ paging, reverse-order, event query, checkpoint-resumable), a lossless ACS read (AcsEntry, incomplete reassignments included), request builders (bounded/filtered/shaped streams, completion user_id), node health. JSON: the same submission set including fire-and-forget and recovery, event query, bounded reads, and WebSocket streaming — updates, completions and a resumable ACS — behind the ws feature. |
canton-admin |
Admin surface: party allocation/management, user self-inspect, packages read, and topology read (party→participant mappings, namespace delegations, vetted packages) over the Canton admin API. |
canton-daml |
The runtime under generated bindings: Daml primitive types (Party, ContractId<T>, Numeric, Timestamp, …), Template/Interface/Choice traits, command builders, and the JSON + gRPC value codecs. |
canton-codegen / canton-codegen-cli |
DAR → typed Rust. The CLI (dpm-codegen-rust, also dpm codegen-rust) writes a complete crate from any DAR; the library is the IR + emitter behind it. |
canton-lf |
Daml-LF archive reader/decoder (the codegen front-end), built on the official daml-lf-archive schema and held to the official JVM reader by a conformance oracle. Internal. |
canton-splice-amulet, canton-splice-wallet, canton-splice-wallet-payments |
Pre-built typed bindings for the Splice protocol DARs, regenerated per release ("DAR as a crate"). |
canton-quickstart-licensing |
The same, for the cn-quickstart licensing DAR. Not published — it backs the reference app and the end-to-end tests; generate your own with the CLI. |
| SDK version | Canton version | Ledger API | Rust (MSRV) |
|---|---|---|---|
| 0.2.x (current) | 3.5.7 (pinned protos) | v2 | 1.88 |
| 0.1.x | 3.5.7 (pinned protos) | v2 | 1.88 |
The vendored .proto files are pinned to the Canton release above; moving the
supported Canton range re-vendors them in a new SDK minor (see the stability
policy in canton-proto and
ADR-0002). All canton-*
crates release in lockstep — mix only equal versions
(ADR-0005).
| Feature | Crate | What it adds |
|---|---|---|
ws |
canton-ledger |
WebSocket streaming for the JSON transport (ws_updates, ws_active_contracts, ws_completions, ws_updates_resumable), TLS-aware. |
otel |
canton-core, canton-ledger |
OTLP span export (telemetry::otel::otlp_tracer) and automatic W3C trace-context injection into outgoing gRPC metadata + JSON headers. |
The canton facade forwards both: canton = { version = "0.2", features = ["ws", "otel"] }.
Telemetry follows the standard Rust model: the SDK emits (tracing spans, metrics counters labelled by method + transport); the application installs the subscriber/recorder of its choice.
cargo add canton # the whole SDK, one crate
# or pick pieces: cargo add canton-ledger canton-authuse canton::ledger::{CantonClient, Config};
#[tokio::main]
async fn main() -> canton::Result<()> {
let client = CantonClient::connect_lazy(Config::new("http://localhost:3901"))?;
println!("ledger api version: {}", client.version().await?);
println!("node health: {:?}", client.health_check().await?);
Ok(())
}With OIDC auth and a command:
use canton::auth::{OidcConfig, TokenProvider};
use canton::ledger::{CantonClient, Config, Submit, create, identifier, record, value};
async fn submit(party: &str, pkg: &str) -> canton::Result<()> {
let auth = TokenProvider::new(OidcConfig::keycloak(
"http://keycloak.localhost:8082", "AppProvider", "client-id", "client-secret",
));
let client = CantonClient::connect_lazy(
Config::new("http://localhost:3901").with_oidc(auth),
)?;
let tx = client
.submit_and_wait_for_transaction(
Submit::new(party).add_command(create(
identifier(pkg, "Licensing.AppInstall", "AppInstallRequest"),
record(vec![
("provider", value::party(party)),
("user", value::party(party)),
("meta", value::record(record(vec![("values", value::empty_text_map())]))),
]),
)),
)
.await?;
println!("committed {} at offset {}", tx.update_id, tx.offset);
Ok(())
}Runnable examples: version_and_health (no auth, defaults to http://localhost:3901) and submit_and_read (OIDC auth + a create). Both read the same CANTON_TEST_* variables as the live tests below, so one export set runs everything:
cargo run -p canton-ledger --example version_and_health
cargo run -p canton-ledger --example submit_and_readWhen the outcome must not be lost. A submission whose response never arrives may still have committed, and the way back to it is the command's identity — so take the identity before sending rather than from a call that may fail:
use std::time::Duration;
// An offset from before the submission, to read completions from.
let offset = client.ledger_end().await?;
let submission = client.submission(Submit::new(party).add_command(command));
if submission.submit_and_wait().await.is_err() {
// Ambiguous — ask the ledger what actually happened. The match is on the
// whole change ID (user, acting parties, command id), not the command id
// alone, which is not unique across a participant's users.
let completion = submission.recover(offset, Duration::from_secs(30)).await?;
println!("committed after all: {}", completion.update_id);
}JsonClient::submission is the same handle on the JSON transport, recovering
over the WebSocket.
See also the integration tests in crates/canton-ledger/tests/ and crates/canton-admin/tests/.
canton-devkit runs a Splice LocalNet — two participants and a super-validator — and exports it into the environment. The SDK reads that export directly, so nothing in the 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
let json = JsonClient::from_env()?; // same network
let user = Config::from_env_for("app-user")?; // the other participant
let party = localnet::party("app-provider"); // the id for `act_as`Runnable: localnet.
cargo run -p canton-ledger --example localnetTwo details this handles for you, both of which otherwise fail late and
unhelpfully. The exported gRPC URL has no scheme (host:port is what a gRPC
client dials) — passed to a client unchanged it produced an unexplained
transport error at the first RPC. And the URLs are nginx virtual-host names
(grpc-ledger-api.app-provider.demo.localhost), so the name has to reach the
:authority / Host header rather than be resolved away; substituting
127.0.0.1 reaches the port and is refused by the vhost. *.localhost resolves
to loopback on macOS and on Linux with systemd-resolved — elsewhere, add an
/etc/hosts entry.
Nothing here is devkit-specific beyond the variable names, and CANTON_ENDPOINT
/ CANTON_TOKEN override them for an environment that is not a LocalNet. The
full contract is in canton_core::localnet.
Verified against a live Canton 3.5.7 participant reached through those exported
shapes — scheme-less gRPC URL, vhost hostnames, ready-made bearer token — on
both transports. What that does not yet cover is a localnet up of our own:
the contract is read and exercised, the orchestration around it is the devkit
project's to vouch for.
Turn any DAR into a typed crate — templates become structs, choices become typed exercise impls, with JSON and gRPC codecs on everything:
cargo install canton-codegen-cli # provides `dpm-codegen-rust`
dpm-codegen-rust --dar path/to/my-app-0.1.0.dar --out my-app-bindingsThe output is a self-contained crate (Cargo.toml + src/lib.rs). Commit it
and depend on it by path — that is how the canton-splice-* crates in this
repository are built, and it keeps the generated code reviewable in a diff:
my-app-bindings = { path = "my-app-bindings" }To keep it in step with the DAR, regenerate in CI and fail on a diff, rather than generating during the build:
dpm-codegen-rust --dar dars/my-app-0.1.0.dar --out my-app-bindings
git diff --exit-code my-app-bindingsNo --force is needed to regenerate over the tool's own output; it is there to
overwrite files this tool did not write, which is a thing to do on purpose
and not a flag to carry around.
Generating from a build script does not work, and it is worth saying why
rather than leaving it to be discovered: Cargo resolves path dependencies before
it runs build scripts, so on a clean checkout my-app-bindings does not exist
yet and the build fails before the script that would create it has run. The
prost-build arrangement — write into OUT_DIR, include! it — does not apply
either: the emitted tree spans several packages and refers between them by
crate::-qualified paths, which resolve to the including crate's root rather
than the module they were placed in.
canton_codegen::generate is a library call for exactly the CI step above, and
for tooling that produces a crate directory.
Then submit typed commands:
use my_app_bindings::my_app::My_Module::{Asset, Asset_Transfer};
use canton_daml as rt;
use rt::Template as _;
let payload = Asset { owner: rt::Party::new(party), name: "gem".into() };
let create = rt::create_command(&payload); // gRPC command
let created: Asset = Asset::from_created_event(&event)?; // typed read
let exercise = rt::exercise_command(&contract_id, &Asset_Transfer {
new_owner: rt::Party::new(other),
});Template ids use the upgrade-friendly #package-name form, so the participant
resolves the version vetted under Smart Contract Upgrade (the pinned package id
is also available as Asset::PACKAGE_ID). For the Splice DARs, skip codegen and
use the pre-built canton-splice-* crates. The full Daml-LF → Rust type mapping
is documented in docs/daml-lf-type-mapping.md;
regeneration on a DAR version bump in
docs/scu-regeneration.md. A complete runnable flow
(typed create → read back → exercise, on gRPC and JSON) is
crates/canton-sample.
No-node tests — unit tests, in-process gRPC/WebSocket mock servers, TLS handshakes, wire-shape assertions. Nothing to install or configure:
cargo test --workspace --all-featuresLive integration tests run against a real participant when the variables
below are set, and skip otherwise (so the command above stays green without a
node). Every name is prefixed CANTON_TEST_:
A skipped test and a passing one are the same line in cargo's output, so set
CANTON_TEST_REQUIRE_LIVE=1 whenever a run is meant to prove something:
each test that would step aside for a missing variable fails instead. That is
what makes "38 live tests passed" a claim about a participant rather than about
an empty environment.
| Variable | What it gates | Example (LocalNet App Provider) |
|---|---|---|
CANTON_TEST_ENDPOINT |
all gRPC live tests | http://localhost:3901 |
CANTON_TEST_JSON_ENDPOINT |
the JSON-transport and WebSocket tests | http://localhost:3975 |
CANTON_TEST_TOKEN_URL |
authenticated tests (OIDC client-credentials) | http://keycloak.localhost:8082/realms/AppProvider/protocol/openid-connect/token |
CANTON_TEST_CLIENT_ID, CANTON_TEST_CLIENT_SECRET |
ditto | app-provider-backend, … |
CANTON_TEST_PARTY |
command submission and read-back | app_provider_quickstart-…::1220… |
CANTON_TEST_LICENSING_PKG |
ditto — the package the test commands instantiate | #quickstart-licensing |
CANTON_TEST_ADMIN_ENDPOINT |
canton-admin topology reads |
http://localhost:3902 |
CANTON_TEST_ADMIN_CLIENT_ID, CANTON_TEST_ADMIN_CLIENT_SECRET |
party-admin RPCs (need the ParticipantAdmin right) |
app-provider-validator, … |
CANTON_TEST_SYNC_ID |
optional: also assert vetted packages in the synchronizer store | |
CANTON_TEST_REQUIRE_LIVE |
turns every skip into a failure — set it on any run whose result is being reported | 1 |
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_PARTY='app_provider_quickstart-…::1220…'
export CANTON_TEST_LICENSING_PKG='#quickstart-licensing'
export CANTON_TEST_REQUIRE_LIVE=1 # skipping is now a failure, not a pass
cargo test -p canton-ledger --all-features --test live -- --nocaptureCredentials. The suite takes whichever the environment offers: the OIDC
client-credentials flow where there is an issuer (CANTON_TEST_TOKEN_URL and
friends), otherwise a ready-made bearer token — which is what a Splice LocalNet
exports, having no issuer to exchange credentials with. Two tests genuinely need
an issuer (ledger_end_with_oidc_auth, and party management, which needs a
token carrying ParticipantAdmin); those say so when they step aside. The rest
run either way.
Bringing up a node. Any Canton 3.5 participant works; four paths, least setup first:
- canton-devkit — one binary:
canton-devkit localnet up demo, theneval "$(canton-devkit localnet env demo)"exports endpoints, tokens and party ids under the namescanton_core::localnetreads, so the suite needs noCANTON_TEST_*at all. It allocates its own ports, so pass--port-baseor read the exported values rather than assuming3901. Authentication is Splice'sunsafe-jwt-hmac-256, so the two issuer-dependent tests skip;localnet dar uploadsupplies a package for the rest. - Canton Builder Tool
— the least to install:
canton builder startbrings up a LocalNet (its guide says about five minutes the first time, faster after), andcanton builder statusprints the port reference. Its App Provider participant is on3901/3902/3975like the others, andcanton builder deploy <dar>uploads a DAR to both participants, which is how you getCANTON_TEST_LICENSING_PKGwithout cn-quickstart. - Splice LocalNet —
plain Docker Compose. Its App Provider participant is on the ports used above
(
3901gRPC,3902admin,3975JSON), soCANTON_TEST_ENDPOINTandCANTON_TEST_JSON_ENDPOINTneed no changes. It runs unauthenticated by default — its only other profile isunsafe-jwt-hmac-256, an HMAC secret you sign tokens with yourself — so there is no OIDC token endpoint. Set the token you signed asCANTON_TOKENand everything except the two issuer-dependent tests runs; the command-submission ones additionally want a package, which is whatcn-quickstartsupplies. cn-quickstart(make setup && make build && make start) — the same LocalNet plus the licensing sample app, which is whereCANTON_TEST_LICENSING_PKG/#quickstart-licensingcomes from; needed for the tests that submit commands.
More LocalNet tooling is catalogued on the Canton Dev Hub.
CI enforces rustfmt, clippy -D warnings (all features), the full test suite on Linux/macOS/Windows, rustdoc -D warnings, cargo-deny, and the MSRV build.
Rust 1.88 (bounded by tonic 0.14). Policy: the MSRV tracks what our
pinned major dependencies require; a bump is a minor (not breaking) change,
announced in the CHANGELOG, and CI always builds the declared
MSRV.
Shipped: the async Ledger API client (gRPC + JSON + WebSocket, auth, TLS,
retry, telemetry) and type-safe code generation from DAR packages — SCU-aware,
with a dpm codegen-rust component and prebuilt canton-splice-* crates. The
LF decoder is native Rust rather than a JVM wrapper around daml-lf-archive
(ADR-0008); its output is held to the
official JVM reader by a conformance oracle.
Next: token-standard support (CIP-56 V1 + CIP-0112 V2), interactive submission with a pluggable signer, a typed PQS client, and the Ledger-Client-Standard conformance suite.
See CONTRIBUTING.md for the development workflow and SECURITY.md for private vulnerability reporting. Notable changes are tracked in CHANGELOG.md.
Built on the Ledger API and Daml-LF work of the Canton and Splice teams.
The local-development path reads the environment canton-devkit exports, and reading its DAR container taught us that a per-entry decompression cap bounds nothing on its own — an archive is now bounded in total as well.
Equilibrium reviewed the released M1 client from an independent engineering perspective and reported a credential leak privately before anything else. Their findings are closed in 0.2.0 and listed in the changelog; several are the kind that only a reader who does not already know what the code meant to do would find.
Apache-2.0. See LICENSE.