Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The token standard

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

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

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

Why a registry is involved

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

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

The registry client

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

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

Choice contexts and disclosed contracts

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

What comes back is a ChoiceContext:

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

What a workflow returns

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

Picking input holdings

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

Running the examples

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

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

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

CIP-56 (V1) transfers

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

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

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

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

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

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

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

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

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

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

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

V1 allocations

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

CIP-0112 (V2)

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

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

Transfers over accounts

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

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

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

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

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

Reading what moved

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

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

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

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

Allocation and settlement

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

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

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

then the settlement, the allocation specification and the call:

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

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

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

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

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

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

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

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

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

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

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

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

Withdrawing an allocation

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

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

Which crate holds which types

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

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

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

Testing

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

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

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