| Category | Technology | Notes |
|---|---|---|
| Language | Daml | LF target 2.1 (build-options: --target=2.1) |
| SDK | Daml SDK 3.4.11 | installed and driven via dpm |
| Build tool | dpm (Digital Asset Package Manager) |
not the legacy daml assistant (removed as of SDK 3.5) |
| Task runner | npm scripts |
the Daml ones wrap dpm with LANG=C.UTF-8; prepare builds registry/ |
| Standard | CIP-0056 (CN Token Standard) | interface-faithful, clean-room (no economics) |
| Dependencies | splice-api-token-* interface DARs |
vendored from canton-network/splice; NOT splice-amulet |
| Testing | daml-script (dpm test) |
lives in the separate canton-token-forge-test package |
| Runtime | Canton ledger; JDK 17+ on PATH |
daml/ Container of dpm packages (mirrors upstream splice layout); NOT a package
canton-token-forge/ Production package (name: canton-token-forge)
daml.yaml SDK/LF pins; data-deps on the 7 splice-api-token-* interface DARs
daml/Canton/TokenForge/
Registry.daml InstrumentConfig rules/factory + preapproval; TransferFactory/AllocationFactory/BurnMintFactory instances
Token.daml Token holding template + HoldingV1.Holding instance; input fetch/consume/spend helpers
Locked.daml LockedToken escrow shared by pending transfers, allocations, and batch transfer locked outputs
Instruction.daml TokenTransferInstruction template + TransferInstruction interface instance
Transfer.daml Batch transfer value types, controller rule, and execution helper (the AmuletRules transfer analog)
Allocation.daml TokenAllocation template + Allocation interface instance
Types.daml mkInstrumentId helper (admin + id -> InstrumentId)
TxMeta.daml tx-kind annotations for the choices the standard does not define
Version.daml package version marker
canton-token-forge-test/ Integration-test package (name: canton-token-forge-test)
daml.yaml data-deps on the compiled production DAR + the interface DARs
daml/Canton/TokenForge/Test/
AllocationTest.daml allocation flow tests
BatchTransferTest.daml Batch transfer shapes, conservation, and authorization negatives
BurnMintTest.daml burn-mint factory tests
FaucetTest.daml faucet tap tests
LockTest.daml LockedToken escrow tests
MultiInstrumentTest.daml multi-instrument isolation tests
OfferTest.daml pending-offer (2-step) transfer tests
RegistryTest.daml registry / instrument config tests
Smoke.daml version-marker smoke script
TokenTest.daml token minting / holding tests
TransferTest.daml direct transfer flow tests
TxMetaTest.daml tx-kind metadata assertions per annotated choice
Util.daml shared test/setup helpers
registry/ Read-only TypeScript HTTP service serving the CN Token Standard registry API
src/
config.ts Env-var config, validated at boot (see Environment Variables)
ledger.ts JSON Ledger API client: ledger end, active contracts, one contract by id, party lookup, disclosures
startup.ts Boot checks: the configured template ids resolve, the admin party exists on the participant and the token can read as it
payloads.ts JSON shapes of the on-ledger payloads the service reads
mapping.ts ACS payload -> API shapes; resolveConfig picks the (admin, instrumentId) config
disclose.ts The single AnyValue encoding site + the three choice-context keys
logger.ts The structural Logger interface plus the pino instance
openapi.ts, server.ts, index.ts Spec loading, middleware stack, process lifecycle
routes/
metadata.ts /info, /instruments, /instruments/:id
transfer.ts Transfer factory + accept/reject/withdraw choice contexts
allocation.ts Allocation factory + execute-transfer/withdraw/cancel choice contexts
lookup.ts, respond.ts Shared ACS lookups and the 404/409 response helpers
async-handler.ts Wraps every route so a rejected promise reaches the error middleware
openapi/ The CN Token Standard OpenAPI specs the service validates against
test/ Unit suites (stub ledger) + test/e2e (live participant, skip-guarded)
consumer-smoke/ Stands in for a downstream repo that received only the release asset
multi-package.yaml Stops dpm resolving the package below against the root workspace
consumer/
daml.yaml Data-depends on the vendored DAR alone; exposes its six bundled Splice packages
daml/Consumer.daml Converts to all seven interface instances, so dropping one breaks this build
scripts/
fetch-dep.sh Vendor Splice sources, derive DAR versions, create stable-name symlinks
build-harness.sh Build the Amulet test harness (unused by default; conformance only)
consumer-smoke.sh Build the DAR, then compile consumer-smoke/ against nothing else
release-notes.sh Emit the release body for a tag, snippet extracted from consumer-smoke/consumer/daml.yaml
sandbox.sh Build the DAR and run a local Canton sandbox with the JSON Ledger API
seed.mjs Seed a running sandbox with an admin, demo users, and one InstrumentConfig
check-registry-deps.mjs Fail when the root and registry manifests disagree on a dependency
registry-install-smoke.sh Pack the npm package, install it into a scratch consumer, and run the bin
deps/ Vendored Splice sources + built DARs (gitignored; never edit or commit)
multi-package.yaml Wires the two daml/ packages into one workspace; consumer-smoke/ has its own
versions.env Single version knob: SPLICE_TAG
package.json npm scripts wrapping dpm
The package is a clean-room implementation of the CN Token Standard: it defines
its own templates but exposes its standardized behavior through the standard
interfaces from the splice-api-token-* DARs. The core module hierarchy:
InstrumentConfig(Registry.daml) - admin-signed per-instrument rules/factory contract (the AmuletRules analog), created directly by its admin.ensureconstrainsdecimalsto 0..10. It carries the four choices we define on it:InstrumentConfig_Mint(free, admin-and-recipient-authorized issuance, no economics),InstrumentConfig_Tap(the optional faucet, controlled by the tapping user alone and capped per tap),InstrumentConfig_Preapprove(controlled by the receiver, creating the opt-in that enables direct transfers), andInstrumentConfig_Transfer(the multi-output transfer that can emit locked outputs, controlled by the sender together with every output receiver and lock holder, and pinned to this config byexpectedAdminandexpectedInstrumentId). Implements theTransferFactory,AllocationFactory, andBurnMintFactoryinterfaces. One admin creates one config per instrument and serves all of them through a single registry API; the instrument identity is(admin, instrumentId).TokenTransferPreapproval(Registry.daml) - receiver opt-in enabling one-step transfers, co-signed byadmin, receiverwith an Amulet-style validity window.Token(Token.daml) - an unlocked holding. Signed byadmin, owner.ensure amount > 0.0. ImplementsHoldingV1.Holding.LockedToken(Locked.daml) - the escrow holding behind a pending transfer, an allocation, or a batch transfer's locked output. Signed byadmin, owner, holders, withexpiresAtset from the transfer'sexecuteBefore, the allocation'ssettleBefore, or, for a batch transfer's locked output, the caller-suppliedTokenLock.expiresAtdirectly. Three helpers return it to its owner, and which one a caller takes is the difference between the abort paths:unlockHolding(no deadline, transfer reject),reclaimAtDeadline(both withdraws) andabortEscrow(the joint allocation cancel alone). Its owner can also reclaim it directly throughLockedToken_ExpireLockonceexpiresAthas passed.optContextsurfaces as the standardLock.context. Three sites create aLockedToken: the pending two-step transfer (Registry.daml) and the allocation (Allocation.daml) each supply their own description of why the funds are held, since the escrow has no caller to ask, while the batch transfer's locked outputs (Transfer.daml) forward the caller-suppliedTokenLock.optContextverbatim.TokenTransferInstruction(Instruction.daml) - a pending two-step transfer, signed byadmin, transfer.senderwith the receiver as observer. ImplementsTransferInstructionV1.TransferInstruction.TokenAllocation(Allocation.daml) - an escrowed leg of a DvP settlement, signed byadmin, allocation.transferLeg.senderwith the executor and receiver as observers. ImplementsAllocationV1.Allocation.TokenTransfer(Transfer.daml) - the batch transfer's argument, and the only one a client assembles by hand, since no registry route builds it. Asender, theinputsto spend, andoutputs, each aTokenTransferOutputofreceiver,amount, caller-suppliedmeta, and an optionalTokenLockofholders,expiresAtandoptContext. The choice pins the config it reaches with a separateexpectedAdminandexpectedInstrumentId, neither of which the argument itself carries.CreatedHolding(Transfer.daml) - what the batch transfer returns per output,TransferResultTokenorTransferResultLockedToken, inoutputsorder because callers pattern match on it positionally.executeTokenTransfer(Transfer.daml) - the choice body, alongsidetokenTransferControllers(every party a transfer would bind into a holding, computed from the argument before anything is fetched) andtokenTransferTxMeta(itsmerge-splitortransferannotation).transferImpl(Registry.daml) - theTransferFactoryinstance body, lifted out of the interface instance and passed the config'sadminandinstrumentIdas explicit params. Its only caller is that instance.mkInstrumentId(Types.daml) - builds the standardHoldingV1.InstrumentIdfrom an admin party and an id string.
The production package data-depends ONLY on the seven splice-api-token-*
interface DARs (holding, metadata, transfer-instruction, allocation,
allocation-instruction, allocation-request, burn-mint) and NOT on splice-amulet.
It integrates with the wider CN ecosystem purely by implementing those standard
interfaces: HoldingV1.Holding, TransferInstructionV1.TransferFactory,
TransferInstructionV1.TransferInstruction, AllocationInstructionV1.AllocationFactory,
AllocationV1.Allocation, and BurnMintV1.BurnMintFactory. There is no
off-ledger I/O in the Daml code; all state lives on the ledger as contracts.
Registration and transfer move through the ledger as follows:
- Register an instrument - the admin creates an
InstrumentConfig. There is no counterparty: the admin is the sole signatory. - Mint -
InstrumentConfig_Mint(controlled byadmin, recipient) creates aTokenholding for the recipient. - Transfer -
TransferFactory_Transferon theInstrumentConfigrunstransferImpl, which spends the sender's inputs (fetches and archives each inputToken, checking sender ownership and instrument, and creates a sender-changeTokenfor any surplus) and then either:- completes directly, creating the receiver
Tokenand returningTransferInstructionResult_Completed, when the transfer is a self-transfer or the choice context supplies a matchingTokenTransferPreapproval; or - escrows the amount in a
LockedTokenand creates a pendingTokenTransferInstruction, returningTransferInstructionResult_Pending.
- completes directly, creating the receiver
- Settle or unwind a pending transfer - the receiver accepts
(
TransferInstruction_Accept, which archives the escrow and creates the receiver'sToken) or rejects it, and the sender may withdraw it. Accept mints against the escrow rather than moving it, so it first checks that the escrow carries the transfer's amount, instrument, and owner, and that the instruction's admin is the instrument's own. Archiving the escrow proves only that its owner authorized the transaction, and the receiver is one of those authorizers, so the owner check is what ties the escrow to the sender. The two unwind paths take different helpers fromLocked.daml, and the split is deliberate: reject returns the escrow to the sender immediately (unlockHolding), since a receiver declining delivery leaves nowhere else for the funds to go, while withdraw is the sender acting alone and so is gated onexecuteBefore(reclaimAtDeadline). Only withdraw can clear the instruction with nothing to return when the choice context reports that the sender already reclaimed the escrow: reject is controlled by the receiver, who could otherwise report a live escrow as gone and archive the record without refunding it. - Allocate for settlement -
AllocationFactory_Allocateescrows aLockedTokenand creates aTokenAllocationholding one leg of a DvP.Allocation_ExecuteTransferdelivers it to the receiver,Allocation_WithdrawandAllocation_Cancelunwind it. - Faucet -
InstrumentConfig_Tapis controlled by the tapping user alone and mints up to the instrument's per-tap cap, when the config declares a faucet. It is the one path that gives a party funds without the admin acting, which is what lets an unfunded party bootstrap itself in a demo. - Burn and mint -
BurnMintFactory_BurnMintarchives input holdings and creates outputs in one atomic step, authorized by the admin plus theextraActorsthe standard expects to carry the input and output owners. - Batch transfer -
InstrumentConfig_TransferrunsexecuteTokenTransfer(Transfer.daml), controlled by the sender together with every output receiver and every output lock holder, and pinned by anexpectedAdminand anexpectedInstrumentIdthat must both match the config it is exercised against: the transfer argument names neither, and one admin may run several instruments. It archives the sender's inputs and creates the outputs in order, each either aTokenor aLockedTokencarrying the caller's ownexpiresAtandoptContextverbatim, with any leftover input value returned to the sender as change. A lock'sholdersis the one field not copied verbatim: it is deduplicated, sorted, and stripped of the output's own receiver, the way Amulet'sdedupOutputLockHoldersdoes it, so a lock naming nobody but the receiver is created with an empty holder list and its owner can release it alone. Output metadata, by contrast, is passed through untouched: a batch has no canonical pairing of N inputs to M outputs to copy a memo along, so a memo survives being locked only if the caller passes it, and the change output carries none. Itstx-kindannotation carries no.../reason, unlike the single-holding lock choice it replaced: one exercise may lock, pay and return change at once, and no one string describes that. Unlike steps 3 to 5, this choice is registry-native rather than a standard interface, andregistry/srchas no reference to it: the registry service does not drive it, so a client submits it directly against the participant.
Steps 3 to 5 are the paths a client drives through the registry service. The two
factory routes supply a factoryId alongside the choice context; the accept,
reject, withdraw, execute-transfer and cancel routes supply the choice context
and its disclosures alone, since the client already holds the instruction or
allocation contract id it is exercising.
The standard's factory and instruction choices take an ExtraArgs carrying a
ChoiceContext (values : TextMap AnyValue); the contracts it points at are
disclosed separately, in the submission's own disclosedContracts. Between them
that is how a choice body reaches a contract the submitting party does not know
about or cannot see: LF 2.1 has no contract keys, so nothing on-ledger can look
a contract up by identity, and the disclosure is what makes a contract readable
to a submitter who is not a stakeholder. The context carries a contract id only
where the body has no other way to name one, which here is the preapproval
alone: the escrow routes leave it to read the lock's id out of the instruction
or allocation being exercised. For a real client the registry service serves
both halves in one response, and registry/src/disclose.ts is its single
AnyValue encoding site. The Daml suite builds the same contexts directly, in
three places: Test/Util.daml's expireLockArgs for the expire-lock signal and
escrowReclaimedArgs for the reclaimed-escrow report,
Test/AllocationTest.daml's bothSignalArgs for the two together, and the
preapproval context inline at each direct-transfer call site in
Test/TransferTest.daml.
Three context keys are ours rather than the standard's, and each is a named
constant on both sides rather than a literal: preapprovalContextKey
(Registry.daml), expireLockContextKey and escrowReclaimedContextKey
(Locked.daml) in Daml, PREAPPROVAL_CONTEXT_KEY, EXPIRE_LOCK_CONTEXT_KEY
and ESCROW_RECLAIMED_CONTEXT_KEY in disclose.ts.
| Key | Value | Read by |
|---|---|---|
canton-token-forge/transfer-preapproval |
AV_ContractId of a TokenTransferPreapproval |
transferImpl, to take the direct path |
canton-token-forge/expire-lock |
AV_Bool true |
abortEscrow, to release an escrow before its deadline |
canton-token-forge/escrow-reclaimed |
AV_Bool true |
returnEscrowUnlessReclaimed, to clear a record whose escrow is already gone |
What each route returns:
| Route | Context data | Discloses |
|---|---|---|
transfer factory, direct |
the preapproval cid | InstrumentConfig + the preapproval |
transfer factory, offer / self |
empty | InstrumentConfig |
| transfer accept / reject / withdraw | empty | the escrow LockedToken |
| allocation factory | empty | InstrumentConfig |
| allocation execute-transfer / withdraw | empty | the escrow LockedToken |
| allocation cancel | the expire-lock signal | the escrow LockedToken |
| transfer withdraw / allocation withdraw whose escrow is archived | the reclaimed-escrow report | nothing |
| allocation cancel whose escrow is archived | the report + the expire-lock signal | nothing |
| any other of these whose escrow is not live | 404 escrow not found |
nothing |
The first two of those rows exist because the escrow has a second exit: after
the settlement deadline its owner can reclaim it through LockedToken_ExpireLock
without going near the record. findEscrow already resolves the escrow on every
one of these routes, so the service reports what it found, and the choice skips
the escrow rather than reaching for a contract that is gone. The third row is
every other way an escrow can fail to be live: accept, reject and
execute-transfer, which have nothing left to act on and no authority to clear
the record without the escrow, and any route whose lookup found nothing at all,
which is not evidence of a reclaim. Both answer 404 rather than report one.
What it reports is the state, not the mere absence of an answer. findEscrow
returns the participant's three: live, archived, and absent. Only
archived earns the report, because only the archive event is evidence the
escrow was ever there and is gone now. The participant supplies that evidence
for free, since a by-id read answers an archived contract 200 with its created
event and an archive event beside it, while a contract of another template, one
the admin cannot see, and one that never existed all answer 404 alike. Reading
that 404 as a reclaim is what let a LOCKED_TOKEN_TEMPLATE_ID naming another
real template manufacture a report for every live escrow on the ledger.
Honoring a report nothing on-ledger backs is safe under two rules, both enforced
by returnEscrowUnlessReclaimed and its callers. First, the reported branch runs
the same gate the escrow-returning branch would, so a report can never buy an
abort the honest path would have refused; that is why cancel still sends its
expire-lock signal when the escrow is gone, since without it the gate is the
settlement deadline. Second, the report is honored only on a choice the escrow's
owner authorizes: forging it then archives a record the owner is party to and
strands the owner's own funds in a lock they can still reclaim. Where that
authority is delegated, as sender and receiver typically delegate the allocation
cancel to the executor, that rule buys less: an executor reporting a live escrow
as gone archives the allocation without releasing it, so the sender waits for
settleBefore and LockedToken_ExpireLock instead of being refunded on the
spot. That is delay rather than loss. Transfer reject is the exception to the
second, being controlled by the receiver alone, and the first cannot stand in
for it: reject's escrow-returning branch runs no gate at all, so honoring the
report there would buy an abort that refunds nothing rather than one that comes
early. It returns the escrow unconditionally, and its route answers 404 like the
settlement routes (transfer accept, allocation execute-transfer), which answer
404 because they have nothing to settle out of.
Two asymmetries in that table are deliberate:
- The factory routes disclose the
InstrumentConfigbecause they must name one contract asfactoryId; the instruction and allocation routes disclose only the escrow. None of the accept, reject or withdraw choice bodies fetches a config, so disclosing one would add a lookup that can fail without adding authority. That matters because the lookup routes throughresolveConfig, which answers 409 when two configs share an(admin, instrumentId): a route that fetches a reference contract no choice reads imports that contract's failure modes into an operation that does not depend on it. - Only
cancelcarries the early-release signal. It is the only path that returns an escrow to the sender under joint authorization: its controllers are the executor, sender and receiver together, and their co-signature is what makes honoring a caller-supplied signal safe.withdrawreturns the escrow too but the sender acts alone, so it is deadline-gated on-ledger and ignores the signal;execute-transfersharescancel's joint controllers but delivers to the receiver rather than unwinding, so it never needs one.
The escrow disclosures exist because a receiver is not a stakeholder of the
LockedToken holding its incoming funds. On the JSON Ledger API a missing
disclosure surfaces as a 404 on submission rather than as an authorization
error, since from the submitting party's side the contract simply does not exist.
| Variable | Purpose |
|---|---|
SPLICE_TAG |
In versions.env. The single Splice release tag every vendored DAR version is derived from. Change it, run npm run setup. |
LANG |
Must be C.UTF-8 for dpm/damlc (otherwise data-dependency interface regeneration throws a UTF-8 decoding error). The npm scripts set it. |
These two cover the Daml build only. The registry service and scripts/seed.mjs
are configured entirely by environment: the service requires eight variables,
refuses to start without them, and additionally validates the five template ids
and ADMIN_PARTY against the participant at boot (registry/.env.example), and the seed reads its
overrides from SEED_*/LEDGER_* (RUNBOOK.md).
| Command | Purpose |
|---|---|
npm run setup |
Vendor Splice into deps/ and create the stable-name symlinks (scripts/fetch-dep.sh). |
npm run build |
Build both packages (production, then test). |
npm run build:canton-token-forge |
Build only the production package. |
npm test |
Build the production DAR, then run the canton-token-forge-test suite. |
npm run test:coverage |
Same as npm test with a template-focused coverage report. |
npm run smoke |
Compile a package that data-depends on nothing but the built DAR (scripts/consumer-smoke.sh); proves the release artifact is consumable on its own. |
npm run check:deps |
Fail if the root and registry/ manifests disagree on any dependency. |
npm run smoke:registry |
Pack the npm package, install it into a scratch consumer, and run the bin; proves the published service is consumable on its own. |
bash scripts/release-notes.sh <tag> |
Emit the release body, with the consumer snippet extracted from consumer-smoke/consumer/daml.yaml. Refuses if <tag> does not name the checked-out commit, if the working tree is dirty, or if deps/ carries no commit stamp (npm run setup writes it); ALLOW_UNTAGGED=1 previews a body before the tag exists (RUNBOOK.md). |
npm run clean |
Remove both .daml build dirs, the consumer smoke test's output, and registry/dist. |
npm run sandbox |
Build the DAR and run a local Canton sandbox with the JSON Ledger API. |
npm run seed |
Seed a running sandbox with an admin, demo users, and one InstrumentConfig. |
bash scripts/build-harness.sh |
Build the Amulet test harness (unused by default; conformance only). |
Amounts are Daml Decimal, which is Numeric 10, so every holding amount
carries ten decimal places whatever its instrument declares. InstrumentConfig
constrains decimals to the range 0..10 via ensure, matching that ceiling and
the token standard's own cap. decimals is display guidance for clients
deciding how to render and accept amounts; it is not a scale enforced on
amounts, so a decimals = 0 instrument can still hold 42.5. Token enforces
amount > 0.0. Transfers sum the input
holding amounts, require the total to cover the requested amount, and emit a
sender-change holding for any surplus, so no value is created or destroyed in a
transfer.
- Signatory model: the admin signs all six templates, and wherever a template
encumbers another party's position that party co-signs:
InstrumentConfig(adminalone, it encumbers nobody),TokenTransferPreapproval(admin, receiver),Token(admin, owner),LockedToken(admin, owner, holders),TokenTransferInstruction(admin, transfer.sender, receiver observing),TokenAllocation(admin, allocation.transferLeg.sender, executor and receiver observing). Holdings are co-signed by their owner, matching Amulet, so the admin cannot archive and re-mint an owner's funds unilaterally. - Single-party registry: one party carries every role. It is the
InstrumentId.admin, signsInstrumentConfigand every holding, is the party the service reads the active contract set as, and is what the metadata API reports asadminId. There is no separate operator. This matches CIP-0056, which models a single instrument admin, and Amulet, where the DSO party is both the instrument admin and the registry app. Reading as any other party is not a configuration choice but a broken one: the admin is a signatory of every contract the service serves, and a non-stakeholder's queries come back empty. - Rules/factory pattern:
InstrumentConfigis the per-instrument rules/factory (AmuletRules analog); it implementsTransferFactory,AllocationFactory, andBurnMintFactory, and exposes minting as a nonconsuming choice. - Module boundary: the helpers that fetch, validate and spend sender inputs
(
fetchInputs/consumeInputs/spendInputs) live inToken.daml,allocateImpllives inAllocation.daml, and the batch transfer's execution helperexecuteTokenTransferlives inTransfer.daml, itself built onconsumeInputs;Registry.damlimports all three, so the standard transfer (both its direct and its pending path, which share onespendInputscall ahead of the branch), the batch transfer and the allocation path spend inputs through one implementation. Each takes the config'sadmin/instrumentIdas explicit params instead of reading them back from the contract. - Clean-room, interface-only: no dependency on
splice-amuletand no economics (no decay, fees, mining rounds, rewards, or DSO governance); issuance is free, authorized jointly by the admin and the recipient, plus an optional per-instrument faucet policy.