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.

100 CC · Verified
Custodial party — holds the coin; balance read from the ACS
farmfort::12204e94c0e449c0efcd270dd1e68259c36471cebef132e5c7dfc2750fe8c9eed77f
The breakthrough

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.

8
Stages confirmed on-ledger
100
Canton Coin received & verified
20
CC transferred to a non-custodial party
01

The run

S1

Authenticate

Confirmed

Keycloak client_credentials against the DevNet IdP returned a valid RS256 bearer token. Cache it, refresh ~30s before expiry.

// RS256 JWT
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwi… (ok)
S2

Allocate a party

Confirmed

Two 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.

custodial party:
farmfort::12204e94c0e449c0efcd270dd1e68259c36471cebef132e5c7dfc2750fe8c9eed77f
S3

Grant the party ledger rights

Confirmed

The 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.

// POST /v2/users/{sub}/rights
{ "newlyGrantedRights": [ { "kind": { "CanActAs": { "value": { "party": "farmfort::12204e94…" } } } ] }
S4

Create a TransferPreapproval (proposal)

Confirmed

Created TransferPreapprovalProposal via submit-and-wait — create-args are simply receiver + provider (no expiresAt), provider resolved from /v0/validator-user. Committed on-ledger.

// submit-and-wait -> 200
{ "updateId": "1220b4424f87…", "completionOffset": 2192501 }
S5

Receive Canton Coin

Confirmed

A 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.

// wildcard active-contracts on farmfort
Splice.Wallet.TransferOffer:TransferOffer // sender = cantor8-digik-1 (validator)
Splice.AmuletTransferInstruction:AmuletTransferInstruction // ×2, backing locked amulet
S6

Accept the offer

Confirmed

Exercised TransferOffer_Accept via submit-and-wait as the receiver — a clean ledger exercise, no disclosed contracts needed, because S3 had already granted the rights.

// exercise TransferOffer_Accept -> 200
{ "updateId": "12201abd0146…", "completionOffset": 2192828 }
S7

Read balance (Holding UTXOs)

Confirmed

Read path: /v2/state/ledger-end/v2/state/active-contracts on the Holding interface, summing the UTXO pieces. Balance is contracts, not an account number.

// Holding interface, post-accept (allowing for PQS lag)
BALANCE: 100.0 across 1 Holding(s) // the Canton Coin is held
S8

Token Standard transfer — full round-trip

Confirmed

Sent 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.

// TransferFactory_Transfer (sender farmfort) -> 200 · transferKind: offer
{ "updateId": "1220a9930700…", "completionOffset": 2192974 }
// TransferInstruction_Accept (farmfort2, self-signed) -> 200
farmfort2 BALANCE: 20.0 across 1 Holding // farmfort retains 80
02

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.

03

Feedback for the lab

Friction — and the answers I found

F1

Allocating a party doesn't grant rights to it

resolved

The 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.

F2

The CC airdrop isn't where the inbox looks

resolved

The 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.

F3

Preapproval create-args weren't discoverable

resolved

Over 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.

F4

"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.

F5

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.

F6

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

W1

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.

W2

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.

W3

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.