Parse the registrar and the label into Holding - #50
gyorgybalazsi wants to merge 10 commits into
Conversation
A whole-instrument comparison needs PartialEq. Callers compare the id and the admin field by field today, which is easy to half-apply. The admin field also carries three names across the layers. The template calls the party registrar, InstrumentIdentifier calls it source, and the interface view calls it admin. This type mirrors the view, so the comment records the mapping where a reader meets it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The struct kept three of the payload's eight fields. It dropped registrar, so a caller could not tell two registrars' CBTC apart, and it dropped label, so nothing could filter by account. instrument_id becomes an InstrumentId built from the payload's registrar and instrument.id, which is verbatim what Holding.daml:57 writes into the V1 view. So this struct and active_contracts::get now compare the same two values. account_id holds the label. The parser reads label as required. Every registry-holding version from 0.0.1 declares the field, confirmed from DA's source and against all 519,386 active mainnet holdings, so a required read breaks no existing holding. An empty label stays a real unlabelled account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The eight workspace members read their version from workspace.package, so bumping it rewrites their entries in the lock file. Only those eight lines change. Leaving them out would dirty the tree on the next build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The breaking API change affects downstream consumers and warrants final human review.
Pull request overview
Updates Holding parsing to retain registrar-based instrument identity and account labels, with a breaking 0.8.0 API change.
Changes:
- Parses
registrarandlabel. - Adds
InstrumentIdequality support and tests. - Documents and versions the breaking change.
File summaries
| File | Summary |
|---|---|
crates/token/src/holding.rs |
Extends holding parsing and adds tests. |
crates/common/src/transfer.rs |
Adds InstrumentId equality and documentation. |
CHANGELOG.md |
Documents the breaking API change. |
Cargo.toml |
Bumps the workspace version. |
Cargo.lock |
Updates workspace package versions. |
Review details
- Files reviewed: 4/5 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
InstrumentId has sat in common::transfer since the initial commit, because Transfer was the first type that needed it. It is not a transfer type. Allocations, splits, consolidations, holdings and the registry all refer to an instrument, and 19 files across three crates reach into common::transfer for it. The Token Standard declares InstrumentId in Splice.Api.Token.HoldingV1, so common::instrument now matches where the standard puts it. The fields and the JSON encoding do not change, and this release already breaks Holding, so there is no compatibility re-export to keep the old path alive. The type also derives Hash, so a caller can key a map by instrument. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A code review raised six findings on this branch. Four are addressed here; one was disproved and one needed no change. Holding.account_id becomes account_label. The field holds the template's label verbatim, and a label is not an account. A V2 account is owner, provider and id together, so a caller who compares this field to an Account.id skips the provider check active_contracts::matches_account performs. The name now makes that misuse read wrong at the call site. The test fixture carried an empty label, so parses_every_field_of_a_holding passed against a parser that ignored the payload and returned the default. The fixture now labels the account, and that test fails under the mutation. The changelog gained a migration line, because the shortest edit that compiles is h.instrument_id.id == "CBTC", which reintroduces the bug. It also said seven error messages where there are eight, and carried a release date three days before the release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Resolve the advertised account_id/implemented account_label mismatch and correct the active-contracts verification claim.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
crates/token/src/active_contracts.rs:86
- The verification text says
active_contracts::integration_get_by_partyand its_v2twin exerciseHolding::from_active_contract, but these tests only callactive_contracts::getand compare contract IDs. That path filters interface-view JSON inwantedand never invokes the concrete-payload parser; the parser is exercised elsewhere by consolidation tests, not by the two tests named here. Please correct this verification claim or add the promised direct parse assertion to these tests.
instrument: &common::instrument::InstrumentId,
- Files reviewed: 23/24 changed files
- Comments generated: 1
- Review effort level: Lite
The pilot asked whether a type called InstrumentId should have a field called id. It should: the Rust type mirrors the Daml one, and both field names are the JSON keys on the wire, so a rename would need a serde attribute that makes the two disagree. The interesting half is why the pair exists. The standard says id "MUST be unique and unambiguous per instrument admin" - per admin, not globally. So id alone does not name an instrument, which is exactly the defect this branch fixes. The comment records that where a reader meets the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment argued from a measurement: no active mainnet holding carries a provider. That is true and too weak, and it implied one could appear. It cannot, for this template. The ensure clause at Holding.daml:49 calls isRegistryAccount, which checks isNone account.provider, so the ledger refuses to create such a contract. That has held since registry-holding 0.3.0. The comment also now separates the account's provider from the payload's own top-level provider field, which is an observer party. A reader who wired that one into the account would build the wrong account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment argued from the ensure clause, which arrived in 0.3.0, and so implied an older holding might differ. It cannot. holdingV1Metadata builds the account with provider = None as a literal, so the field is never read from the payload, at any version. The ensure clause tests a constant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment said the account can never carry a provider, which reads as a property of the standard. It is a property of registry-holding 0.3.2. The Token Standard's Account has a provider, and DA has said it intends to use it in a later iteration. The citations now name 0.3.2 and its line numbers. The previous ones came from 0.3.0 and were eight lines out, though every claim still held: the account derivation is unchanged between the two, and TokenApiUtilsV2.daml is identical. The label claim extends to 0.3.2 for the same reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issue #51 names the three construction sites that change when the registry populates the account provider, and says why we cannot build the fix now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hubagaspar91
left a comment
There was a problem hiding this comment.
[pragmatic] Review — 3 comments, all on doc comments. No code findings.
The parser change is sound, and this pass found no code defect. All three comments below concern the new doc comments.
One general request. Several new comments cite exact code lines in Digital Asset's Daml sources (Holding.daml:57, Holding.daml:247, TokenApiUtilsV2.daml:116, HoldingV1.daml:16-19). A line number drifts with any upstream edit, and nothing detects the drift. Please cite a symbol name plus a pinned package version instead, in every new comment.
| pub contract_id: String, | ||
| pub amount: DamlDecimal, | ||
| pub instrument_id: String, | ||
| /// The instrument, admin included. The payload's `registrar` is the | ||
| /// admin, which is what `Holding.daml:57` writes into the V1 view. | ||
| pub instrument_id: InstrumentId, | ||
| pub owner: String, | ||
| /// The account label, verbatim from the template's `label` field. | ||
| /// | ||
| /// This is not a whole account. A V2 account is `owner`, `provider` and | ||
| /// `id` together, and `registryAccount` (`TokenApiUtilsV2.daml:57-59`) | ||
| /// derives one from `owner` and this label, with no provider. | ||
| /// | ||
| /// Through registry-holding 0.3.2 the account carries no provider, so | ||
| /// `owner` plus this label determines it. `holdingV1Metadata` builds the | ||
| /// account with `provider = None` as a literal (`Holding.daml:247`), so | ||
| /// the field is not read from the payload at all. The `ensure` clause at | ||
| /// `Holding.daml:50` tests the same property through `isRegistryAccount` | ||
| /// and `isNone account.provider` (`TokenApiUtilsV2.daml:116`), which | ||
| /// cannot fail while the value it tests is a constant. | ||
| /// | ||
| /// **That is a fact about today's registry, not a property of the | ||
| /// standard.** The Token Standard's `Account` has a provider, and Digital | ||
| /// Asset has said it intends to use it in a later iteration. When it does, | ||
| /// a label stops determining an account on its own, and a caller needs a | ||
| /// whole `Account`. This field is named for what it holds so that day | ||
| /// changes what callers build, not what this field means. Issue #51 | ||
| /// records the three sites that change, and why we cannot build the fix | ||
| /// before DA ships theirs. | ||
| /// | ||
| /// Do not confuse the account's provider with the payload's own top-level | ||
| /// `provider` field. That party is an observer (`Holding.daml:43`), and it | ||
| /// is not the account's provider even now. The view ignores it, and so | ||
| /// does this parser. | ||
| /// | ||
| /// So `owner` plus this label determines the account here. Comparing this | ||
| /// field to an `Account.id` still skips the provider check that | ||
| /// `active_contracts::matches_account` performs, which matters if a | ||
| /// caller ever holds an account from another source. | ||
| pub account_label: String, |
There was a problem hiding this comment.
[pragmatic] This comment mixes two kinds of content. The caller contract belongs here: the field holds the template's label verbatim, a label is not an account, and a comparison against Account.id skips the provider check in active_contracts::matches_account. The version-pinned proof does not belong here. It cites registry-holding 0.3.2 by Daml line numbers, and nothing in this repo detects when those citations go stale. The drift has already started: this comment cites Holding.daml:43 for the observer fact, and the PR body cites Holding.daml:42 for the same fact. Please trim the comment to the caller contract plus one pointer to issue #51. Please move the Daml proof and the roadmap paragraphs into issue #51 and the design doc. Where a citation must stay, name the symbol and the package version, for example holdingV1Metadata in registry-holding 0.3.2.
| /// The eight fields `Utility.Registry.Holding.V0.Holding` declares. Every | ||
| /// registry-holding version from 0.0.1 to 0.3.1 declares the same set, and | ||
| /// all 519,386 active mainnet holdings carried all eight on 11 Sep 2026. |
There was a problem hiding this comment.
[pragmatic] Please drop the census sentence. The count was stale one day after the measurement, and a future reader cannot re-check it. The PR body already carries it, where it supports the one-time merge decision. Keep the first sentence about the eight declared fields. Also, this comment says the versions run 0.0.1 to 0.3.1, and the parser comment in from_active_contract says 0.0.1 to 0.3.2. Please align the two ranges.
| /// The party that administers the instrument. | ||
| /// | ||
| /// The utility registry calls the same party `registrar` in its `Holding` | ||
| /// template, and `source` in `InstrumentIdentifier`. `Holding.daml:57` |
There was a problem hiding this comment.
[pragmatic] Holding.daml:57 here, and HoldingV1.daml:16-19 below, cite another organization's repo by line number. Any upstream edit invalidates them silently. Please cite the symbol and pin the version instead, for example: holdingV1Metadata in registry-holding 0.3.2 maps registrar to admin. The explanation that registrar, admin and source are one party under three names is worth keeping.
What this PR does
Old behavior
A
Holdingcontract on the ledger carries eight fields.token::holding::Holdingread three of them — the amount, the owner and the ticker — and discarded the
other five. It also kept the contract id, which comes from the ledger event
rather than from the payload.
Two of the discarded fields matter.
registrarnames the party that administersthe instrument.
labelnames the account the holding sits in.A caller cannot tell two registrars' instruments apart without
registrar. Anyregistrar can issue a token called
CBTC. On devnet one party already holdslegacy
CBTCV0RC8holdings beside real ones. A caller that matched on the tickeralone selected holdings that the registry then rejected, with
400 Given holdings are invalid.Nothing could filter holdings by account without
label, because the account aholding sits in is exactly what that field records.
Separately,
InstrumentIdlived incommon::transfer, where it had sat sincethe initial commit because
Transferwas the first type that needed it.New behavior
Holdingnow keeps five of the eight fields instead of three. It readsregistrarandlabelas well.The parser puts
registrarintoinstrument_id. That field used to be a plainstring holding the ticker, such as
"CBTC". It is now a pair: the ticker, andthe party that issued it. This library calls that party the
admin, and theDaml template calls the same party the
registrar. They are one party undertwo names. That is why the parser reads one name and writes the other.
The parser puts
labelinto a new field calledaccount_label.So a caller can now answer two questions it could not answer before. Who issued
this holding? Which account does it sit in?
A caller that compares the ticker alone no longer compiles. That is deliberate.
The ticker does not identify an instrument on its own, so the old comparison
accepted another registrar's
CBTCwithout saying so.Separately,
InstrumentIdnow lives incommon::instrumentrather thancommon::transfer.Verification
cargo test --workspace— 192 tests ran, zero failures.holding.rshad notests at all and now carries 19, and every one of those 19 runs. They cover
the five parsed fields, all eight error messages, and the lock check.
The suite spans 18 binaries, but nine of those hold no test, and another 39
tests across the workspace carry
#[ignore].cargo clippy --workspace --all-targets -- -D warnings— clean, on a fresh--target-dir. That run took 22 seconds, so it is a real lint pass rather thana cache hit.
cargo fmt --check— clean.The ignored integration suite — 27 of 27 against devnet, in 655 seconds:
registry2 andtoken25, run withcargo test -p registry -p token -- --ignored --test-threads=1 integration_.These tests read real
Holdingpayloads off a live ledger and parse themthrough the widened struct, so the JSON field names are measured rather than
inferred.
The run covers commit
8be135e. Every commit since changes doc comments andnothing else, so none of them can move a ledger result:
git diff 8be135e..HEADshows only comment lines.Two of the 27 carry most of the weight:
client::integration_check_and_consolidate,in both its V1 and V2 forms. They are the only tests that exercise the parser
on payloads the ledger actually sends.
consolidate_utxosis the only production path that builds aHolding, and it takes its contracts from an interface-filtered ACS query. Ifthat query returned no
createArgument, every parse would fail. The testguarantees at least two UTXOs and then asserts
consolidated,utxos_after == 1and a preserved balance, so reaching those assertions proves real payloadsparsed through the new required reads.
What this does not cover. This repository has no CI. There is no
.githubdirectory, so no automated gate runs on this PR and
gh pr checksreportsnothing. Every number above comes from a local run on one machine. Issue #35
tracks that, and I have added this PR to it as a worked example.
The 19 unit tests on their own do not prove the JSON field names are right,
because they read a hand-written fixture. The devnet run above is what closes
that, and the fixture also matches the Daml template, which I read from source.
The devnet run proves parsing, not discrimination. Every holding it met was
CBTCunder one admin, so it never met two registrars issuing the same ticker,and it never met a non-empty
label. It shows the parser reads what the ledgeractually sends. That two different registrars now produce two different
InstrumentIdvalues is shown by the unit tests alone —reads_the_id_out_of_the_instrument_objectand
the_admin_comes_from_registrar_not_from_instrument_sourceare the two thatcarry it.
The
cbtc-libdevnet suite has not run against this change. It runs oncbtc-libPR 66, which repins to this release.The change that enables it
Holding.instrument_idchanges type fromStringtocommon::instrument::InstrumentId, which carries an admin beside the ticker.Holdinggainsaccount_label: String, read from the payload'slabel.registrarfor the admin.InstrumentIdmoves fromcommon::transfertocommon::instrument, andderives
PartialEq,EqandHash.Caveats
This breaks every consumer that compares a ticker, and every consumer that
imports
InstrumentId.canton-libreleases it as0.8.0for that reason.cbtc-libhas five ticker-comparison sites plus two doc comments, andcbtc-libPR 66 fixes them in the same repin.The migration has a wrong answer that compiles. Writing
h.instrument_id.id == "CBTC"builds fine and reintroduces the exact bug thisremoves. Compare the whole
InstrumentId. The changelog says so too.account_labelholds a label, not an account, and that is a deliberate beton the registry changing. A Token Standard
Accountisowner,providerand
id. The utility registry populates only two:holdingV1Metadatabuildsevery account with
provider = Noneas a literal (Holding.daml:247,registry-holding 0.3.2). So
ownerplus this label determines the accounttoday, and a single
Stringis enough.Digital Asset intends to use the provider in a later iteration, and the
utility implementation of Token Standard V2 is not final. When that lands, a
label stops determining an account, and three construction sites in this
library change: this field,
TokenClient::account_for, and the batch CSVreceiver. #51 tracks
it, and explains why we cannot build the fix before DA ships theirs.
This is the reason the field is not called
account_id. A field named for thelabel it holds keeps its meaning when the account model grows; a field named
account_idwould have to change meaning.The field has already paid for itself, on devnet, the day it landed. A
reviewer of
cbtc-libPR 66 found that its V2 integration run burned alabelled holding while
check_balanceread the unlabelled account, so thebalance never moved and step 18 failed with "Balance did not decrease after
withdrawal".
list_holdingsfilters by instrument and not by account, andregistry-holding does write labels, so this is reachable rather than
theoretical. The fix selects unlabelled holdings for the burn — expressible
only because this field exists. Filtering holdings by instrument alone can
still pick a holding from an account you did not mean to touch.
One unparseable holding will fail a whole call once a consumer filters on
these fields. That behaviour lives in
cbtc-lib'slist_holdings, not in thisdiff, and the design records it as accepted there.
Re-date the changelog at merge if it slips. The
0.8.0heading reads2026-09-14, following this repo's convention of dating a release the day it
ships.
Details
Three things the diff does not show
instrument_idkeeps its name and changes its meaning. The type movesfrom
StringtoInstrumentId.Stringheld the ticker alone, andInstrumentIdholds theadmin and the ticker. No rename marks the change, so read every use of the
field rather than scanning for renamed ones.
instrument.admintoinstrument.source. TheDaml type
InstrumentIdentifierdeclaressource,idandscheme, and noadmin. An earlier branch's fixture taught a field name that does not exist.payload.registrarand writesinstrument_id.admin. Bothnames are correct, and they denote the same party.
Holding.daml:57maps themthe same way when it builds the V1 interface view. The doc comment on
InstrumentId.adminrecords the mapping, and it names the third spelling too:InstrumentIdentifier.source.Why
InstrumentIdmovedIt is not a transfer type. Nineteen files across
token,commonandregistryreach for it, and allocations, splits, consolidations and holdings are not
transfers. The Token Standard itself declares
InstrumentIdinSplice.Api.Token.HoldingV1, so the old location had this repo's own doc commentpointing at a Holding module while the file sat in a transfer one.
The move is import paths only. Fields and JSON encoding do not change. There is
no compatibility re-export, because this release already breaks
Holdingand alingering
pub usewould preserve the wrong mental model for another release.Why
account_labelis not calledaccount_idIt holds the template's
labelverbatim, and a label is not an account. A V2account is
owner,providerandidtogether. A caller who writesh.account_label == account.idis visibly comparing a label to an id, and isskipping the provider check that
token::active_contracts::matches_accountperforms.
Through registry-holding 0.3.2 the account carries no provider, so
ownerplusthe label determines it.
holdingV1Metadatabuilds the account withprovider = Noneas a literal (Holding.daml:247), so the payload's provider isnever read into it. The
ensureclause atHolding.daml:50tests the sameproperty through
isRegistryAccountandisNone account.provider(
TokenApiUtilsV2.daml:116), which cannot fail while the value it tests is aconstant.
That is a fact about today's registry, not about the standard. The Token
Standard's
Accounthas a provider, and Digital Asset has said it intends to useit in a later iteration. When it does, a label stops determining an account on
its own. Naming the field for what it holds means that day changes what callers
build rather than what the field means — which is the main reason not to call it
account_id.Do not read the payload's own top-level
providerfield as the account'sprovider. That party is an observer (
Holding.daml:42). The parser ignores itdeliberately, and a reader who wired it into the account would build the wrong
one.
Why
labelparses as requiredEvery
registry-holdingversion from 0.0.1 to 0.3.2 declareslabelas arequired
Text, 0.3.2 being the current one, shipped in utility 0.14.4. I readthat from Digital Asset's source. All 519,386 active mainnet holdings carried the
field on 11 Sep 2026.
A required read separates two cases that an optional read merges. An absent field
means the contract is not a registry
Holdingat all. An empty string means areal holding in an unlabelled account. The tests cover both.
Why the parser reads
registrarrather thaninstrument.sourceBoth fields hold the same party. The template's
ensureclause callshasValidInstrumentIdentifier, which forces the two to agree, so a check thatthey match could never fail. The parser reads
registrar, because that is thefield the interface view reads.
The parser also ignores the payload's top-level
provider.holdingV1Metadatabuilds the account with
provider = None, so the payload'sprovideris not theaccount's provider.
Why there is no
holding::v2moduleThis type parses
createArgument, which is the concrete template payload. Thatpayload is identical under Token Standard V1 and V2. The V1 and V2 split in the
other eight modules exists because registry routes and choice arguments differ,
and holding parsing touches neither.
What the move did to the rest of the diff
consolidate.rsis the only non-test consumer ofHolding, and its logic needsno change, because it reads
.amountand.contract_idonly. It does appear inthe diff, with 11 insertions and 11 deletions, and every one of them is an
InstrumentIdimport path. The same is true of most of the 24 changed files.Notes
0.8.0, which rewrites eightversionlines inCargo.lock. Eight of the nine workspace members inherit it;crates/ledgrpcis
canton-proto-rsand pins its own.fixture corrected as described above, beside 5 new ones.
Design:
cip-112docs/specs/2026-09-11-canton-lib-holding-struct-design.md·Consumer:
cbtc-libPR 66 · CI gap: #35 ·Closes the parser half of
DLC-link/cbtc-lib#74🤖 Generated with Claude Code