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.