Canton DevNet · Low-Level Lab
Touching
the Ledger.
The full Canton lab, hand-rolled over raw JSON — no wallet, no dApp, no SDK. It runs end to end: 100 Canton Coin received and verified on-ledger, then 20 of it sent on to a second, non-custodial party that signs its own transactions — a complete transfer round-trip between two parties I own. A party allocated, granted its own ledger rights, transfers accepted by hand, every balance read straight out of the Active Contract Set. Below is the real trace — including the two walls that cost the most time and exactly how each fell — plus honest feedback for the lab team.
The wall wasn't cryptography — it was authorization. A freshly allocated party 403s on submit-and-wait: allocating a party does not grant your user the right to act as it. The fix was a single self-grant — POST /v2/users/{sub}/rights with CanActAs on the party (the hackathon user turned out to be a participant admin). One grant, and every command after it cleared.
The run
Authenticate
ConfirmedKeycloak client_credentials against the DevNet IdP returned a valid RS256 bearer token. Cache it, refresh ~30s before expiry.
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwi… (ok)
Allocate a party
ConfirmedTwo were stood up: a custodial party via /v2/parties (validator signs for it), and an external non-custodial party via /v0/admin/external-party/topology/{generate,submit} with a locally-held ed25519 key. Both are genuinely hosted on the participant. The coin work runs on the custodial party.
farmfort::12204e94c0e449c0efcd270dd1e68259c36471cebef132e5c7dfc2750fe8c9eed77f
Grant the party ledger rights
ConfirmedThe unlock. A custodial party 403s on submit-and-wait until your user holds CanActAs on it. Self-granted over the Ledger API — the participant honoured it because the lab user is a participant admin.
{ "newlyGrantedRights": [ { "kind": { "CanActAs": { "value": { "party": "farmfort::12204e94…" } } } ] }
Create a TransferPreapproval (proposal)
ConfirmedCreated TransferPreapprovalProposal via submit-and-wait — create-args are simply receiver + provider (no expiresAt), provider resolved from /v0/validator-user. Committed on-ledger.
{ "updateId": "1220b4424f87…", "completionOffset": 2192501 }
Receive Canton Coin
ConfirmedA proposal is only half — the provider must accept it to become a live TransferPreapproval. That didn't happen, so the team sent CC the manual way: a Splice.Wallet.TransferOffer (the amulet wallet flow), not a Token Standard TransferInstruction. A wildcard ACS dump found it — an interface-scoped inbox check on the wrong template showed nothing.
Splice.Wallet.TransferOffer:TransferOffer // sender = cantor8-digik-1 (validator)
Splice.AmuletTransferInstruction:AmuletTransferInstruction // ×2, backing locked amulet
Accept the offer
ConfirmedExercised TransferOffer_Accept via submit-and-wait as the receiver — a clean ledger exercise, no disclosed contracts needed, because S3 had already granted the rights.
{ "updateId": "12201abd0146…", "completionOffset": 2192828 }
Read balance (Holding UTXOs)
ConfirmedRead path: /v2/state/ledger-end → /v2/state/active-contracts on the Holding interface, summing the UTXO pieces. Balance is contracts, not an account number.
BALANCE: 100.0 across 1 Holding(s) // the Canton Coin is held
Token Standard transfer — full round-trip
ConfirmedSent 20 CC from farmfort to farmfort2 over the Token Standard rail. The factory needs the complete TransferFactory_Transfer args — requestedAt/executeBefore, inputHoldingCids, and a two-phase context (request the factory, then exercise with the returned choiceContext + disclosed contracts). It created an instruction, and farmfort2 accepted it by signing with its own ed25519 key — interactive prepare → sign → execute, registry accept-context + 4 disclosed contracts. A real transfer between two parties I own: custodial sender, non-custodial receiver.
{ "updateId": "1220a9930700…", "completionOffset": 2192974 }
// TransferInstruction_Accept (farmfort2, self-signed) -> 200
farmfort2 BALANCE: 20.0 across 1 Holding // farmfort retains 80
What got built
A reusable lab runner
canton_lab.py (httpx) wraps the flow — JWT refresh, internal and external party allocation, ACS-by-interface query, Holding-balance sum, a pending-transfer check, validator-info to fetch the provider, and the transfer-factory exercise — a subcommand per stage.
The unblock: grant + preapprove
unblock.py diagnoses the user's rights, self-grants CanActAs on the party via POST /v2/users/{sub}/rights, then creates the preapproval. This is the script that broke the 403 logjam.
Find-the-coin & accept
inbox.py is a wildcard ACS dump that surfaced the TransferOffer a filtered inbox missed; accept.py exercises TransferOffer_Accept and polls the balance. verify.py is a read-only confirm of the preapproval + balance.
The full transfer round-trip
send.py builds the Token Standard TransferFactory_Transfer — timestamps, inputHoldingCids, two-phase factory context; accept_external.py has the non-custodial receiver accept by signing TransferInstruction_Accept with its own key. Custodial out, non-custodial in.
Shapes validated against the live node
Confirmed against the participant's /docs/openapi: node is Canton 3.5.5. Note: querying a template (e.g. TransferPreapprovalProposal) with an interface filter 404s — match the filter to the type.
Feedback for the lab
Friction — and the answers I found
Allocating a party doesn't grant rights to it
resolvedThe single biggest stall. A custodial party from /v2/parties 403s ("a security-sensitive error") on submit-and-wait because the user has no actAs on it. Answer: grant CanActAs via POST /v2/users/{sub}/rights (or have the team grant it). One line in the sheet saves everyone an hour.
The CC airdrop isn't where the inbox looks
resolvedThe team sends coin as a Splice.Wallet.TransferOffer, not a Token Standard TransferInstruction — different mailboxes. An inbox check on the Token Standard interface shows nothing, which reads as "lost." Answer: a wildcard ACS read finds it; accept with TransferOffer_Accept. Worth stating which template the airdrop uses.
Preapproval create-args weren't discoverable
resolvedOver pure REST the TransferPreapprovalProposal fields aren't findable — the OpenAPI carries HTTP shapes, not Daml templates. Answer: create-args are simply receiver + provider (no expiresAt). A sample payload would unblock instantly.
"Internal party" vs the external-party endpoints
The sheet says create an internal / custodial party, then routes to /v0/admin/external-party/topology/{generate,submit} — which is the external path. One line naming each (and that a generated party-id ≠ a fully-onboarded party) removes a real source of confusion.
PQS lag isn't called out
After a transfer, Holding contracts can take minutes to surface while the Participant Query Store syncs. Without a note, an empty balance reads as a broken setup. Poll, don't panic.
Coin distribution is a manual round-trip
Routing every guest through the team for CC is a bottleneck under time pressure. A scoped DevNet faucet for lab parties would let people self-serve through the back half at their own pace.
What landed well
The "bare metal" framing earns its keep
Going under the wallet and dApp abstractions builds real intuition for what those layers hide. Granting your own party rights, then accepting a transfer by hand, is the thing that makes Canton's trust model click.
The mental model maps cleanly
Party = identity, validator = access, ACS = current truth, Holding = balance in pieces, Token Standard = reusable rail. The most portable thing in the lab — keep it front and centre.
Live /docs/openapi is a great safety net
Pulling the node's own spec to confirm versions and request shapes mid-build was decisive every time a field was off. Worth pointing at explicitly in the sheet.