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.