Canton.
- STATUS
- Live on testnet
- CRATE
- tenzro-bridge
- STABILITY
- Stable
- REFERENCE
- Canton 3.5.1
Adapter
CantonAdapter in tenzro-bridge speaks the Canton 3.5+ JSON Ledger API v2. It bridges between Tenzro TNZOand Canton CIP-56 Canton Coin holdings, exposes the participant’s DAML contract set to callers, and runs the DAML submit-and-wait command path. All access flows through the Tenzro node’s scoped API surface.
Read surface
21 MCP tools at https://canton-mcp.tenzro.network/mcp and matching JSON-RPC methods at https://rpc.tenzro.network:
canton_list_domains— synchronizer domains the node is configured againstcanton_list_contracts— active-contracts query withtemplate_idsfilter (the node attaches the live ledger-end offset and the resolved FQ party id automatically)canton_list_parties—GET /v2/parties/knowncanton_list_packages—GET /v2/packages— installed DAR package idscanton_health— combined/livez+/readyz+/v2/versionprobe with version + CIP feature flagscanton_version— participant version + CIP feature flags (verified Canton 3.5.1)canton_get_my_user— returns the Canton user record for the presented credential (CIP-26 User Management)canton_coin_balance— CIP-56 Canton Coin balance fromSplice.Amulet:Amuletcontractscanton_fee_schedule— latestSplice.AmuletRules:AmuletRulesactive contractcanton_connected_synchronizers—GET /v2/state/connected-synchronizerscanton_get_transaction,canton_get_events,canton_get_balancecanton_get_my_analytics— per-API-key call counters (calls_total, errors_total, per-method counts, first_seen_at, last_called_at) for the presented credentialcanton_list_api_key_analytics— operator admin-read across every tenant (admin-token-gated)canton_list_idps— list configured Canton identity providers (admin-token-gated)canton_watch_party— active-contracts query scoped to an explicit party. The presenting key must be authorized for that party: either it matches the key’sprimaryParty, or the party is on the key’scan_read_as_parties/can_act_as_partieswhitelist. Anything else returns-32004.
Write surface
canton_submit_command— DAML create / exercise via the submit-and-wait JSON Ledger API path. The presenting API key must carry a boundcanton_user_id; the node resolves the user’sprimaryPartyand uses it asactAs. Optionally passact_as: <party>to pin a specific party — the node verifies the party either matches the key’sprimaryPartyor is on the key’scan_act_as_partiesagent-delegation whitelist, otherwise the call returns-32004. A key withoutcanton_user_idis rejected (the operator’s default party is never inherited).canton_allocate_party—POST /v2/parties— returns the fully-qualified party id<hint>::<participant-hash>canton_grant_user_rights—POST /v2/users/{userId}/rights— grantCanActAs/CanReadAson a party to a tenant’s user (CIP-26). Required before a newly-allocated party can be acted on. Users under a non-default identity provider also needidentity_provider_id.canton_list_user_rights—GET /v2/users/{userId}/rights— inspect what a tenant can act/read as.canton_upload_dar— DAR upload viaPOST /v2/packageswith a singleContent-Type: application/octet-streamheader. Canton 3.5+ rejects requests carrying duplicateContent-Typeheaders.canton_transfer,canton_create_asset,canton_dvp_settlecanton_reconnect_synchronizer— submitPOST /admin/participant/synchronizer/{alias}/reconnectvia the Admin API; used after a synchronizer outage or planned disconnection
Operator-only writes
These methods write contracts under the operator’s participant-default party or mutate per-tenant IdentityProviderConfig entries. They are admin-token-gated at the node — non-admin callers see -32001. Tenant API keys cannot produce these contracts or registrations.
tenzro_mirrorWorkflowToCanton/tenzro_canton_mirrorReceipt— mirror a Tenzro workflow into a Canton synchronizer as aTenzro.Workflow:WorkflowAnchorcontract. The contract owner is the operator’s participant-default party — the mirrored payload is internal node state.tenzro_mirrorObligationToCanton— mirror an Obligation under an already-mirrored workflow as aTenzro.Workflow:ObligationAnchorcontract.canton_create_idp/canton_delete_idp— manage CantonIdentityProviderConfigentries. Required for the Stage 2.b per-tenant IDP flow.tenzro_canton_listApiKeyAnalytics/tenzro_canton_aggregateAnalytics— operator admin-read across every tenant’s call counters.
Verified wire-level facts (Canton 3.5+)
Four verified facts pin the adapter to the live Canton 3.5+ contract:
- Active-contracts requests use the
eventFormatwrapper. Canton 3.5 dropped the legacy top-levelfilterfield.POST /v2/state/active-contractsnow expects{ eventFormat: { filtersByParty, filtersForAnyParty, verbose }, activeAtOffset }. The legacyfilter+ top-levelverboseshape is rejected withInvalid value for: body. activeAtOffsetis a JSON number.POST /v2/state/active-contractsrejectsnull, empty string, or negative values with HTTP 400. The adapter fetches the current ledger-end viaGET /v2/state/ledger-endbefore every query and serializes the result as a number.- Party ids must be fully qualified. The
filtersByPartymap keys andrequestingPartiesarrays must use<hint>::<participant-hash>form. The adapter resolves the bare party hint to its FQ form via CIP-26 User Management on first call and caches the result. - DAR upload requires a single
Content-Typeheader. The bridge constructs the upload request directly (not through the JSON-tagged helper) so the only Content-Type sent isapplication/octet-stream.
Access — operator API key
Access is granted via a scoped tnz_<base64url> API key with scope canton, presented as the X-Tenzro-Api-Key header (REST) or api_key JSON-RPC param. Methods gated by this scope match the patterns tenzro_*Canton*, tenzro_canton_*, and tenzro_*Daml* (case-insensitive). See auth surfaces for the contract.
Multi-tenant isolation
Each developer team is isolated behind its own API key. The node refuses Canton submissions from any key that does not carry a bound canton_user_id — there is no fallback to an operator-default party. When the key is bound, the node resolves the user’s primaryParty via CIP-26 User Management and uses it as actAs. Canton’s AuthService enforces per-user CanActAs rights server-side as defence-in-depth.
Callers can override the default by passing act_as: <party> on the JSON-RPC call (or as the trailing argument on the Rust / TS SDK methods). The node authorizes the override against the key: either the party matches the key’s resolved primaryParty, or it appears on the key’s can_act_as_parties agent-delegation whitelist. Anything else is rejected with -32004. The operator’s own party can never be used by a tenant key.
Tenant onboarding is automated in a single operator call. When the operator mints an API key with the Canton binding set, the node atomically allocates a tenant party, registers the Canton user with that party as primaryParty, and grants the user CanActAs on its primary party. After issuance, the bound key auto-resolves actAs / requestingPartiesas the tenant’s party on every Canton call.
The response includes canton_primary_party and a canton_provisioning summary. Pass auto_provision_canton: false in the JSON-RPC params to opt out (e.g. when the tenant has been pre-provisioned out of band). The provision step is idempotent: re-issuing a key for an existing Canton user reports status: already_exists and reuses the existing primary party.
Per-tenant analytics
Every canton-scoped RPC call increments a per-API-key counter in RocksDB (CF_CANTON_ANALYTICS). The aggregate captures calls_total, errors_total, per-method counts, plus first_seen_at and last_called_at— the answer to “how many DAML transactions has the tenzro-labs team submitted this month?” Two RPCs surface the counters:
tenzro_canton_getMyAnalytics— subject self-read for the presented API key. No admin token required.tenzro_canton_listApiKeyAnalytics— operator admin-read across every tenant. Admin-token-gated.
CLI: tenzro canton my-analytics for self-read, tenzro canton list-analytics for operator admin-read. SDKs expose getMyAnalytics() / listApiKeyAnalytics(keyId?) on CantonClient. The Python MCP exposes canton_get_my_analytics / canton_list_api_key_analytics.
Per-tenant OAuth isolation
For deployments that require dedicated OAuth clients per tenant rather than a shared operator principal, the node provisions per-tenant OAuth isolation automatically. With canton.identity_providers.enabled set, a single tenzro_createApiKey call atomically mints a per-tenant upstream OAuth2 client via the operator’s configured identity-provider management API, allocates the tenant party, creates the Canton user <client_id>@clients, and grants CanActAs. The minted credentials are stored on the key record — the tenant’s only credential is the tnz_... API key. The response’s tenant_oauth_clientfield carries client_id, issuer URL, and audience for the operator’s records; the client secret never leaves the node.
On every canton-scoped call the node mints and caches the tenant’s Canton JWT server-side from the stored credentials and forwards it via CantonAdapter::with_tenant_jwt, so the operator credential never appears on the wire for tenant requests — a leaked operator JWT does not touch tenant data, and a tenant never runs a token-acquisition loop. Tenants on their own upstream issuer may instead present a JWT via the X-Canton-Auth: Bearer <jwt> header. Canton’s AuthService matches the JWT’s sub to the tenant’s user and enforces CanActAs server-side. Revoking the Tenzro API key tears down the upstream OAuth client and the Canton-side rights in lockstep.
Three operator RPCs back the IDP lifecycle: tenzro_canton_createIdp, tenzro_canton_listIdps, tenzro_canton_deleteIdp — admin-token-gated. Configure via the CANTON_IDP_* environment variables on the node.
Verified Canton 3.5 wire shape
The POST /v2/commands/submit-and-wait-for-transaction request body has two non-obvious wire constraints that Canton 3.5 strictly enforces:
- Outer
commandswrapper. TheJsCommandsobject is nested under a top-levelcommandskey:{ "commands": { "commands": [...], "commandId": ..., "userId": ..., "actAs": [...] } }. A flat body (theJsCommandsfields at the root) is rejected with HTTP 400 and surface errorJSON decoding to CNil should never happen at 'commands.commands[0]'. - Externally-tagged
JsCommand. Each entry in the innercommandsarray is encoded by circe as an externally-tagged union:{ "CreateCommand": { "templateId": ..., "createArguments": {...} } }or{ "ExerciseCommand": { "templateId": ..., "contractId": ..., "choice": ..., "choiceArgument": ... } }. The internally-tagged shape ({ "commandType": "create", "templateId": ..., ... }) is silently rejected as none of the union variants match.
The bridge produces both shapes correctly. A regression test in tenzro-bridge::canton::tests::submit_request_nests_jscommands_under_commands_key pins the serialised bytes offline.
DvP
Delivery vs Payment via on-Canton atomic swap of asset against payment. Choreographed through DAML templates and exercised via canton_dvp_settle.