Add Token Standard V2 to the transfer path - #31
Conversation
cargo clippy --workspace --all-targets -- -D warnings failed on main with 17 errors in token. Every task in the Token Standard V2 plan gates on that command, so the gate could not tell a new regression from an inherited one. No behaviour change: nine collapsed ifs, two div_ceil, two no-op clones on a Copy type, one redundant closure, two let-and-return, and one infallible conversion that was written as a fallible one. Two of the clones were inside submit_sequential_chained, which the V2 work mirrors line for line, so fixing them here stops V2 inheriting them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The V2 work adds four more routes, and a mistyped path segment reads as an opaque 404 from an integration test. A pure builder makes each route unit-testable without a network call. Also removes a placeholder test in accept_context that asserted nothing and returned early when its env vars were absent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One choice-context function covers accept, reject and withdraw, following the shape allocation_context already uses. V1 keeps its accept-only function; giving V1 the same treatment is filed separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each V2 entry point differs from its V1 twin in two steps: the registry URL with its choice arguments, and the exercise command. Everything else is the same. Holding these in shared helpers keeps the V2 work from duplicating six modules. build_submission is pure so a unit test can pin the wire format; a pinned test written before this commit still passes after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Holding selection took a bare party, so a caller holding several account labels got all of them. The V2 consolidate and distribute paths merge what they fetch into one account, so label-blind selection would have moved holdings between labels with no error and no signal. The label is already in hand: a V2 holding's V1 interface view carries it as cip-112/account.provider and cip-112/account.id. The filter is opt-in, so every V1 caller passes None and behaves exactly as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CSV format does not change. Each bare receiver is lifted to a basic account; carrying labels in the file belongs to the label work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Accept, reject and withdraw take the same two fields in V2, so one command builder serves all three. The V2 withdraw path calls the registry's withdraw route; V1 calls the accept route and is filed separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both are self-transfers. The registry compares whole accounts to detect a merge-split, so one Account value goes on both sides of the transfer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config gains a version field. It has no default at the struct level, because TokenClientConfig derives neither Default nor a builder, so every literal must now name it; the workspace has one, in test_utils.rs. No consumer depends on crates/token, so nothing outside recompiles. SendParams, SplitParams and DistributeParams are unchanged: the facade fills the V2 fields from what it already holds. TokenClient::connect performs a Keycloak login, so no unit test can build a client the ordinary way. Every boundary is an HTTP POST to a URL from config, so a wiremock server intercepts all of them and the real connect works against it. The tests assert the URL and the JSON body that leave the process. Five methods query the active-contract set over a websocket before their first registry call, so no HTTP stub can tell their two arms apart. The devnet suite is their only cover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every devnet test that drives a dispatching operation now runs twice, once per Token Standard version. Each body became a plain async fn taking a version, called from an `integration_<name>_v1` and an `integration_<name>_v2` wrapper. `integration_utxo_count` stays single: its two calls reach `active_contracts::get`, which serves both versions, so a second run would spend a devnet round trip proving nothing. `registry` gains `integration_transfer_factory_v2`, the first call this repository makes to the V2 factory route beyond a probe. It is what settles the account encoding end to end: `owner` and `provider` go on the wire as explicit nulls. The workspace moves to 0.7.0. `crates/token` appears in a tag for the first time, and no consumer can break on it: all four pin canton-lib by git tag and none depends on `crates/token`. The integration suite was not run. It stops before its first network call on a missing `PARTY_ID_1`, so the unit gates are this change's only evidence. Filed as a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Seven comments still addressed the implementer rather than the reader. One told a maintainer not to run clippy until a step of a plan the file does not carry. Another said `require_owner` has no caller, which this branch made false by giving it seven, and an `#[allow(dead_code)]` sat under it hiding that. Five more led with a plan task number. Removing the suppression is the part that matters. It would have hidden the guard becoming unwired, which is the one failure of this helper that would cost anything. Two metadata key literals also lost their duplicates. `client::send` and the integration-test helper each re-spelled a key that `utils` already owns, so a key change would have edited one copy and left the other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`v2::withdraw_batch` shipped as new public API with four branches and no test of any kind. `TokenClient` cannot reach it, so the paired devnet tests do not reach it either. It takes contract ids directly and never queries the active-contract set, so both of its boundaries are plain HTTP. Stubbing the registry choice-context route and the ledger submit endpoint reaches every branch, which is why these tests need no websocket. The single-offer test is the one worth having. It asserts that a batch of one is recorded as it failed and never resubmitted. Rewriting the guard as `!batch.is_empty()` makes that test fail on the submission count, which is how the branch was verified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A V2 client read every account label while spending only its basic account. So `utxo_count` and `check_and_consolidate` counted different sets, and `holdings` handed back cids that `split` would refuse to treat as its own. The pull request deferred this, arguing that account-aware reads would change what a V1 caller sees. That is not so: the client already dispatches on the version at ten call sites, so a V2-only arm leaves every V1 caller untouched. `read_account_for` holds the choice, because `holdings` queries the active-contract set over a websocket and no unit test can reach it. Flipping the two arms fails the new test. The V2 split arm now calls `holdings` again rather than repeating the account-scoped query, and its comment saying it must not is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces a large cross-crate API/behavior expansion (V2 parallel modules + client dispatch) and the integration suite is currently not runnable/passing in CI, so it needs final human validation.
Pull request overview
This PR adds Token Standard V2 support across the token transfer path while preserving existing V1 APIs/behavior, enabling per-call (and per-TokenClient) selection of V1 vs V2 registry routes and allowing V2 operations to use Account (owner/provider/id) rather than bare parties.
Changes:
- Introduces
common::TokenStandardVersionand addsTokenClientConfig.version, withTokenClientdispatching each operation to V1 or V2 endpoints. - Adds parallel V2 modules (
...::v2) for transfer/split/consolidate/accept/reject/withdraw/distribute/batch plus V2 registry routes (transfer_factory::v2,accept_context::v2). - Adds shared helpers in
token::utils(submission envelope + metadata helpers) and introduces account-aware holding selection viaactive_contracts::Params.account.
File summaries
| File | Description |
|---|---|
| crates/token/src/utils.rs | Refactors JSON extraction and adds shared helpers/constants for metadata, submissions, and V2 account-owner guarding. |
| crates/token/src/transfer.rs | Reuses new helpers for V1, adds V2 transfer entry points (including sequential chained transfers) and guards/tests. |
| crates/token/src/test_utils.rs | Updates integration test utilities to construct clients per token-standard version and centralizes metadata key usage. |
| crates/token/src/split.rs | Refactors V1 to shared helpers and adds V2 split implementation/tests using self-transfer via V2 factory. |
| crates/token/src/reject.rs | Refactors V1 to shared submission helper and adds V2 reject path with tests for choice/route wiring. |
| crates/token/src/lib.rs | Re-exports TokenStandardVersion from common. |
| crates/token/src/distribute.rs | Ensures V1 uses account-less reads and adds V2 distribute path operating on Account with early owner guard + test. |
| crates/token/src/credentials.rs | Small refactor using if let chaining for created-event parsing. |
| crates/token/src/consolidate.rs | Adds account-aware UTXO counting, refactors V1 submission, and adds V2 consolidate/check-and-consolidate entry points + tests. |
| crates/token/src/client.rs | Adds TokenClientConfig.version, V1/V2 dispatch for all operations, account-aware reads for V2, and wiremock-based dispatch tests. |
| crates/token/src/cancel_offers.rs | Refactors submissions to shared helpers, uses div_ceil, and adds full V2 withdraw APIs + unit tests for withdraw_batch. |
| crates/token/src/batch.rs | Adds V2 CSV batch path (same CSV format) and pairs integration tests across versions. |
| crates/token/src/allocation.rs | Updates ACS reads to pass account: None (explicit V1 behavior). |
| crates/token/src/active_contracts.rs | Adds optional account-label filtering (CIP-112 metadata keys) plus unit tests for label matching. |
| crates/token/src/accept.rs | Refactors V1 submissions, uses div_ceil, and adds V2 accept/accept_all with shared instruction builder + tests. |
| crates/token/Cargo.toml | Adds wiremock as a dev-dependency for dispatch testing. |
| crates/registry/src/transfer_factory.rs | Extracts V1 URL builder and adds full V2 transfer-factory route support + URL tests + integration test. |
| crates/registry/src/accept_context.rs | Extracts V1 accept-context URL builder and adds V2 choice-context routing (accept/reject/withdraw) + URL tests. |
| crates/common/src/transfer.rs | Adds V2 wire types (v2::Account, v2::Transfer) with explicit Optional serialization tests. |
| crates/common/src/transfer_factory.rs | Adds V2 factory choice arguments type (actors, no expectedAdmin). |
| crates/common/src/submission.rs | Adds V2 submission enum variants (TransferFactoryV2, AcceptV2) and tests to pin untagged variant ordering. |
| crates/common/src/lib.rs | Introduces TokenStandardVersion enum (default V1). |
| crates/common/src/consts.rs | Adds V2 template/interface identifiers for transfer factory/instruction. |
| crates/common/src/accept.rs | Adds V2 instruction choice-arguments shape (actors + extraArgs). |
| CHANGELOG.md | Documents 0.7.0 additions, including V2 transfer path, dispatch behavior, and account-aware reads. |
| Cargo.toml | Bumps workspace version to 0.7.0 and adds wiremock workspace dependency. |
| Cargo.lock | Updates lockfile for 0.7.0 and new dev dependency graph (wiremock and transitive deps). |
Review details
- Files reviewed: 26/27 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Persona review — disposition summaryFour Claude persona lenses reviewed this PR: security, PM-against-spec, pragmatic and testing. Each read the full diff independently. I verified every finding against the source before acting on it, and I checked the load-bearing claims against the Daml rather than against this PR's own description. Fixes landed in three commits,
By category
The three that mattered
Verified against the Daml, and left alone
Filed rather than fixed
#44 is the security finding and it is worth reading. A V2 factory payload whose choice context carries a tag Two gaps in this review, stated plainly
Gate status. |
The integration harness said both parties share one Keycloak account, and built party 2's client with party 1's credentials. A participant authorises a token for one party, so on a deployment where the two parties have separate Keycloak users every party-2 read failed with gRPC PERMISSION_DENIED — a security-sensitive error that names no cause. Eight of the 25 devnet tests failed this way, in V1 and V2 alike: batch_from_csv, transfer_accept_all, transfer_offer_accept and transfer_offer_cancel_reject. With this change all 25 pass. KEYCLOAK_CLIENT_ID_2, KEYCLOAK_USERNAME_2 and KEYCLOAK_PASSWORD_2 each fall back to the party-1 twin, so an environment where the parties really do share an account needs none of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`v2::submit` guarded the sender account and not the receiver, so a caller could name a receiver with `owner: None` and reach the registry unguarded. `submit_sequential_chained` already guards every recipient, so the two entry points disagreed. `HoldingV2.daml:27-32` reserves `owner: None` for accounts the instrument admin manages, and names the burn target as one. This library is not the instrument admin and states that it cannot operate such an account, so it must not target one either. That restriction decides the case: the guard costs the library the ability to transfer into a burn target, and the library has no burn path to lose. Burns delegate to an external `BurnMintFactory` and never reach the transfer factory. The two guards do not share a justification. This one enforces the restriction above. `submit_sequential_chained` guards each recipient because it reads the receiver's owner, for `TransferResult` and for `generate_unique_reference`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing tested that `accept::v2::submit`, `reject::v2::submit` and `cancel_offers::v2::submit` pass their own `CHOICE` constant. The existing tests call `instruction_command` directly and build the command themselves, so they pin the constant's value rather than the path that ships. That left the V1 bug reintroducible on V2. Point `reject::v2::submit` at accept's constant and it fetches the reject context, then exercises `TransferInstruction_Accept` against it. The old test still passed under exactly that edit; the new one fails on the choice name. Each test drives the whole operation against a stub serving its two HTTP boundaries, then reads the submitted command back. It asserts the choice, the context route, the derived actors and `actAs`, so a wrong route and a wrong acting party fail it too. The stub lives in `test_utils` because all three need it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The declaration comment above `ChoiceArgumentsVariations` explains the variant order by four claims. Three had tests. The first — `AllocationFactory` precedes `Accept`, which requires only `extraArgs` — had none. Moving `AllocationFactory` after `Accept` now fails this test. Before it, that reorder passed the whole suite and silently turned every allocation payload into an `Accept` with its allocation dropped. The CHANGELOG note also now says what the account guard covers, which is both sides of a transfer rather than the sender alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Persona review — round 2, and the loop's outcomeRound 1's summary is above. Two things in it are now out of date, and one gap it named is closed. The security lens did report, and it retracted its one blocking finding. It had read the account-label metadata keys as wrong, on the evidence that no Its verdict on the rest: nothing blocking. Round 2 findings and dispositions
The finding worth reading twice Nothing tested that That distinction is not academic. Pointing The general lesson, which came out of comparing notes with the session that built the branch: a mutation check only tests what the test actually calls. Flipping a constant and watching a test fail proves the test reads the constant. It says nothing about whether the shipped path does. Every fix in both rounds was verified by mutation, not by the suite going green. Each one was re-run against a deliberately broken version of the code it guards, and each failed as intended: the retry guard rewritten as Outcome: converged on the review side. No blocking finding is open. Fifteen issues carry what was deliberately deferred, #44 being the one with a security dimension. Two gaps remain, and neither is mine to close. Copilot review is not enabled for this repository, so this PR has had no non-Claude automated reviewer. And the devnet run predates the last four commits, so it wants one more pass before merge. The merge call stays with a human. |
A security pass over the review's own commits found four weaknesses in the tests it added. Three are fixed here, and the fourth was a doc. The retry test's only discriminator was that three submissions went out. A regression from per-offer retry to whole-batch retry sends three too, so the test would have passed it. It now asserts how many commands each submission carried: two for the failed batch, then one each. Resubmitting the whole batch gives [2, 2, 2] and fails. Nothing crossed `BATCH_SIZE`, so an off-by-one in `chunks` was uncaught. Six offers must split five and one. No test covered a mixed retry, where one offer succeeds and another fails. A single failure count cannot express that, so `withdraw_stub_sequence` drives the responses in order instead. The three submit-level choice tests asserted the context path ends with their choice, which a regression to the V1 prefix satisfies. They now pin `/transfer-instruction/v2/` as well. `utxo_count`'s doc still described Canton's soft limit as though the count were per party. On a V2 client it is per account, so the number can sit under the limit while the party sits above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review loop closed — devnet green on the reviewed codeThe devnet suite re-ran after the review commits. 25 of 25 pass, zero failures. Run against The run completed rather than stopping early, and that was checked rather than assumed. All 25 test lines end
Where the review ended
Five lenses ran: security, PM-against-spec, pragmatic and testing over the whole change, then a second security pass over the review's own commits. That last one found three of the review's new tests weak, which is where the pattern note in the description came from. Two gaps remain, and neither is closable from a session. Copilot review is not enabled for this repository, so this PR has had no non-Claude automated reviewer — requesting the bot returns success and it never reviews. And nothing schedules the devnet run, so it is green because a person ran it: #35 for the job, #40 for the part that can run on every push without credentials. The merge call stays with a human. |
There was a problem hiding this comment.
🔵 Needs a closer look
The change set introduces a large new V2 API surface and dispatch logic across multiple crates, so it warrants final human review despite tests passing and only minor performance concerns noted.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
crates/token/src/consolidate.rs:141
consolidate_utxoscallsactive_contracts::gettwice (once to deriveinput_holding_cidsand again to price/validate them). This adds an extra websocket round-trip and introduces a race where the ACS can change between calls, producing inconsistent results. Consider fetching contracts once and reusing the same list both to buildinput_holding_cids(when absent) and to computeholdings/total_amount.
This issue also appears on line 435 of the same file.
crates/token/src/consolidate.rs:439
- This V2 consolidation path queries the active-contract set twice (once to collect
input_holding_cidswhen none are supplied, then again to load the same contracts for pricing). That’s extra websocket work and can fail spuriously if the ACS changes between the two calls. Consider fetching contracts once and reusing them to derive both the IDs and the pricedholdings.
ledger_host: params.ledger_host.clone(),
party: owner.clone(),
access_token: params.access_token.clone(),
instrument_id: params.instrument_id.clone(),
// Consolidate only within the caller's own account.
- Files reviewed: 26/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
Both consolidate_utxos paths queried the active-contract set twice: once to derive input_holding_cids, then again to price them. The set can change between the reads, so the code priced a different holding set than the transfer went on to spend. In V2 an archive between the reads also tripped the account-mismatch guard, which reported it as holdings not belonging to the account — the wrong diagnosis. The first read now caches its snapshot and the second is skipped. A caller that supplies its own cids still pays exactly one read, and neither the empty nor the single-holding early return pays any. Membership tests over that snapshot move from Vec::contains to a HashSet, so the filter stops being quadratic in the holding count. Reported by Copilot on PR 31, and filed as #46.
Two V1 integration tests had no V2 counterpart, and both read through the account filter: integration_utxo_count and integration_get_by_party. The PR description claimed utxo_count does not dispatch, which was wrong — it reads through holdings, and read_account_for returns None on V1 and the client's own account on V2. The filter's logic was tested but its wiring was not. matches_account had three unit tests; nothing asserted that the selection in active_contracts::get calls it. Deleting the call passed the whole suite. The predicate now lives in a pure `wanted` function, because `get` opens a websocket and no unit test can reach it, and an_account_filter_reaches_the_selection fails when the account check is removed. Verified by injecting exactly that edit. The two new devnet tests are honest about their limit. Every account this library builds is basic, so no devnet holding carries a label key and neither test can catch a filter that keeps too much. They catch the other direction: a filter that drops holdings a V2 caller then cannot spend. client_for went away with its last caller; client_for_version replaces it.
A devnet run lost its connection to DA's registry mid-suite and failed integration_check_and_consolidate_v2 with "error sending request for url". The route was fine: four other V2 tests used it in the same run, and the test passed on its own straight after. Nothing retried, so one dropped connection failed a test outright. post_json now makes up to three attempts, and only when no answer arrived: a refused connection, a timeout, or a request this client could not build. A response is never retried whatever its status, because a 4xx or a 5xx is the registry's answer and repeating the call would hide it. Each attempt now carries a 30s timeout. There was none, so a hung connection stalled the caller forever and is_timeout never fired — the retry could not have seen the failure it exists for. The retries are immediate. The failure is a dropped connection to a load-balanced host, where the next attempt reaches another backend. A backoff would need a timer and this crate has no async runtime of its own. Wired into the four transfer-path routes. allocation_context and allocation_factory keep their own clients; no allocation code calls them. wiremock joins the registry dev-dependencies, already a workspace one.
hubagaspar91
left a comment
There was a problem hiding this comment.
Review — 11 items, medium and above
I ran a persona review with six lenses: security, regression against main, external integration, product-manager against the spec, pragmatic, and testing. I then filtered the result twice, so this list is short on purpose.
What I removed. I checked every candidate against your PR description. You declare 15 of them as decisions or accepts, so I dropped those. I also dropped every pre-existing problem this branch does not cause, and every cosmetic nit. The 11 items below stem from the new code, and each one is medium or above.
What I confirmed, and did not report. I measured the gates myself in a clean worktree at 38d81ca: 160 passed, 0 failed, 39 ignored, and main gives 88 / 0 / 27. Clippy exits 0 with -D warnings, and cargo fmt --check exits 0. Every gate number in your description is accurate.
I found no V1 regression. I proved several V1 paths identical rather than assuming it: all ten extracted Submission sites reproduce main field for field with read_as: None, both V1 registry URLs are byte-identical, active_contracts::Params.account: None reproduces main at every call site, and a51a5e5 is semantics-preserving across all twelve edits. Public API is purely additive in common, registry, ledger and keycloak.
I also confirmed your reasoning on two points I had doubted. Issue #44 is genuinely latent, because every runtime use of ChoiceArgumentsVariations only constructs it. And the V2 devnet suite is real coverage: every V2 operation has a paired test, and the assertions compare balances and UTXO counts rather than checking Ok.
Two items I would fix before merge, both cheap: the anchored comment on the retry helper, and the CHANGELOG.
Thank you for the description. It is the most reviewable PR body I have read in this repository, and it saved me from reporting a dozen things you had already settled.
The crate held four copies of the read-check-parse sequence, one per registry route, and two of them were new on this branch. Each get function is now a URL plus one call, so the status check and the error wording live in one place. A single test can now cover the failure path of all four routes. The four copies worded their errors four ways. They now share one wording, which changes the text a caller sees on a registry failure. No caller matches on that text. Add the test the retry exists for. The three existing tests end in a failure or a single request, so an implementation that retried and then returned the first error passed all three. I checked that by writing exactly that implementation: the new test failed and the other three still passed. It stubs a first attempt that outlives the per-attempt timeout, then asserts two requests arrive, the call succeeds, and both requests carry the same body. Addresses two review threads on PR 31.
A chained V2 transfer posts its first recipient to the registry in the template transfer it builds, and the per-recipient owner guard runs later, inside the loop. So a registry-managed first recipient reached the registry, which the PR description already claimed it could not. distribute::v2::submit reaches this path. validate now checks recipients[0].receiver. I watched the new test fail against the old code, reporting Ok where it required an error. The sender check stays first, so an existing caller sees the same error when both are wrong. validate is V2-only and V1 holds no Account, so no V1 caller can reach the new error. Addresses the security thread on PR 31.
Eleven sites across both chained-transfer functions repeated the same tail: fire the callback, push the result, bump a counter. Each site also chose which counter to bump, so a site could bump the one that disagreed with its own success flag. A Recorder now owns the results and both counts, derives the count from result.success, and each site is one call. The struct literals stay. The eleven sites set success, the two contract ids, the reference, the raw response and the error to different values, so centralising construction would need a parameter per field and buy nothing. A new TransferResult field therefore still touches those literals. I have said so on the thread. Extract the batch logging block too. batch.rs held it twice, word for word, and both versions log the same SequentialChainedResult, so one function serves both without a generic. Addresses two duplication threads on PR 31.
Five V2 parameter structs existed for no gain. AcceptAllParams and WithdrawAllParams were field-identical to their V1 twins, and the three instruction params differed only by renaming the contract-id field to transfer_instruction_id. That rename forced five dispatch arms in TokenClient to restate every field, so each of the five methods built the same struct twice. V2 now re-exports the V1 types. Each of the five methods builds its params once and matches only on which submit to call. The contract id carries the same value under both versions, so the V1 name is accurate in V2. The registry crate keeps transfer_instruction_id. There the name describes the V2 URL segment it fills, so it stays. This changes the public API: five structs are gone and one field is renamed. Decision 2 fixed the party field in V2 and said nothing about the contract-id name, so the reviewer read it as free to change and I agree. Addresses the struct-duplication thread on PR 31.
Three gaps the review found, all in the fast local check. No test drove a registry error response through an operation, in either version. Every stub answered 200, so the non-success branch shared by all four registry routes had no cover, and nothing proved an operation reports a 4xx as an error rather than as a parse failure. One test now mounts a 404 on the context route and asserts accept::v2::submit fails naming the status and the body. Removing the status check turns that error into "Failed to parse registry response", which the test catches. The dispatch stub did not serve the ledger, so the send and split tests asserted the registry call only, while accept, reject and withdraw already read the submitted command back. The stub now answers the submit endpoint, Submitted carries the exercised template id, and both V2 factory tests assert the V2 interface. Pointing factory_command at the V1 interface fails both. incoming_offers and outgoing_offers hold no version dispatch, and both compared transfer.receiver with as_str. An account object there returned None, so the offer left the list silently and accept_all reported "no pending transfers". party_field now warns when the field is not a party string, and both methods say why they are version-neutral. Addresses three review threads on PR 31.
The 0.7.0 entry held only Added and Notes, so a consumer reading the file at the tag bump saw no behaviour change. The retry is one: it alters four transfer-path routes for every caller, not only for the tests. cbtc-lib calls two of those routes. The new Changed section names the four routes, the 30-second per-attempt timeout, the three-attempt budget, the 90-second worst case, and the two allocation routes that keep the old behaviour. It also records the unified error wording, the required account field on GetUtxoCountParams, and the five V2 parameter structs this review removed. Addresses two review threads on PR 31.
The admin conjunct arrived in 5997f9d, whose message names the hazard it prevents, and 20e6e14 tightened the match. Neither shipped a test. Every existing case in active_contracts holds admin fixed at admin::1220ef and varies only id, so deleting the admin comparison left the suite green. utils could not be tested at all: the same comparison sat inline in a closure inside fetch_transfers, which opens a websocket. wanted_transfer is extracted for the reason active_contracts::wanted already exists. The offer path is the reachable one. A TransferOffer names its receiver as an observer only, so any registrar can create one at any party. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch wrote the heading on 1 September, the day the work finished. The release goes out on 9 September, so the file claimed a ship date eight days early. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What this PR does
Old behavior
canton-libspoke exactly one version of the Canton Token Standard: V1. Every transfer, split, consolidation, accept, reject, withdraw and CSV batch went to the registry's/transfer-instruction/v1/...routes and sent bare party strings for the sender and the receiver. A caller who wanted the V2 API had no way to ask for it.New behavior
A caller chooses V1 or V2 per call, at feature parity. Every V1 entry point keeps its name and its behaviour, and every V2 item lives in a
v2submodule beside its V1 twin. Two existing things do change, and both are in Caveats: the four transfer-path registry routes now retry a request that never reached the host, andconsolidate::GetUtxoCountParamsgained a requiredaccountfield. Two things a caller can now do that it could not before:TokenClientConfig.versiontoTokenStandardVersion::V2and have every operation on that client — send, accept, reject, cancel, distribute, split, consolidate, batch — go to the V2 routes.Account(owner/provider/id) instead of a party string, so a caller can name a specific account of an owner, and holding selection can filter to that account's holdings.Verification
Unit gates, run on the branch at the commit this PR proposes:
cargo test --workspace— PASS, exit 0. 170 passed, 0 failed, 39 ignored at the head commita0f46ae. The 39 are 33 ignored unit tests and 6 ignored doctests. This read 160 when first written, at38d81ca; the branch has gained ten passing tests since, four of them ina0f46ae.cargo clippy --workspace --all-targets -- -D warnings— clean, exit 0. No warnings.cargo fmt --check— clean, exit 0.origin/mainmeasures 88 passed, 0 failed, 27 ignored by the same command, in a clean worktree. Its 27 are 21 ignored unit tests and the same 6 ignored doctests. So this branch adds 72 passing unit tests and 12 ignored ones. The 27 counts both kinds and the 31 above counts only unit tests, so those two numbers are not comparable.Why 33 unit tests are ignored. Every one of them carries the same reason string —
integration test: requires live devnet and env vars. Each test drives a real participant and the devnet registry, so it needs Keycloak credentials and a reachable node. The whole set takes about eleven minutes.cargo testtherefore skips it, and a person runs it with-- --ignored. That run is the devnet suite reported below. The twelve tests this branch adds are eleven_v2twins plusintegration_transfer_factory_v2. The branch also renames the ten existing integration tests with a_v1suffix, so the ignored count rises by twelve rather than twenty-two.The 6 ignored doctests are a separate thing, and this branch does not change them. They are
```ignoreexamples onaccept_context::getand on threeconsolidatefunctions. Each example names a placeholder host and a placeholder access token, so running it would make a network call.rustdocneither compiles nor runs them, on this branch or onmain.The integration suite ran against devnet at the head commit
38d81ca, and it is green: 27 of 27 pass.An earlier run of this suite lost its connection to DA's registry and failed one test on a transport error, naming no status code. That is what
38d81caresponds to: the registry POST had no retry and no timeout, so one dropped connection failed a test outright. Two clean runs have followed, at09cf0e8and at the head.Every V2 test passes alongside its V1 twin, so the two versions agree operation by operation against a live registry and a live participant:
registry::transfer_factorytransfer_offer_accepttransfer_accept_alltransfer_offer_cancel_rejectcancel_alldistributesplitsplit_totalcheck_and_consolidatebatch_from_csv|
utxo_count| ok | ok ||
active_contracts::get_by_party| ok | ok |The last two rows are new in
09cf0e8and both passed on their first devnet run.Every other ignored test is version-independent and needs no twin: the three
credentialstests,amulet_rules,mining_rounds,ledger_end, the two websocket tests, and theledgercrate's ownget_by_party, which takes no instrument and no account.integration_transfer_factory_v2is the one that mattered most. It settles spec 6.1's account encoding: the registry accepted{"owner": …, "provider": null, "id": ""}emitted by this code. Before this run the encoding was inferred from a transfer someone else had sent.That run also closes what would otherwise be this PR's weakest point. Five dispatch arms —
accept_all,cancel_all_offers,distribute,consolidateandcheck_and_consolidate— cannot be separated by the local HTTP stub, because each queries the active-contract set before its first registry call and the stub 404s theledger_end::getthat query makes. All five now have devnet evidence instead. Issue #40 records how to cover them locally as well, which matters because nothing schedules the devnet run.What is still not covered: most V2 operation bodies have no unit test. Fourteen runtime functions are covered for their entry guard, their choice constant, or their pure helper, but never for the HTTP-call-then-ledger-submit sequence between them. That is a deliberate accept, recorded in the plan, and the devnet run above is what stands in for it.
Three unit tests do drive a whole operation against a stub and read the submitted command back, so
accept::v2::submit,reject::v2::submitandcancel_offers::v2::submitare each proved to send their own choice — the regression they exclude is #32 on the V2 path.v2::withdraw_batchis covered the same way, across all four of its branches, becauseTokenClientcannot reach it. Every added test was verified by injecting the regression it names and watching it fail, which matters because a mutation check only exercises what the test actually calls, and a count is no discriminator when the bug produces the same count.The suite needed a harness fix first. The harness built party 2's client with party 1's credentials. A participant authorises a token for one party only, so every party-2 read failed with gRPC
PERMISSION_DENIED, an error naming no cause. Eight of the 25 tests failed that way, identically in V1 and V2, which is what showed it was environmental rather than a V2 regression.KEYCLOAK_CLIENT_ID_2,KEYCLOAK_USERNAME_2andKEYCLOAK_PASSWORD_2now supply party 2's credentials, each falling back to its party-1 twin. The defect predates this branch:client_foralready did this at074f41b. The environment also needs several variable overrides, which #39 records.The change that enables it
v2submodule beside each V1 module:common::{transfer, transfer_factory, accept}::v2,registry::{transfer_factory, accept_context}::v2, andtoken::{transfer, split, consolidate, accept, reject, cancel_offers, distribute, batch}::v2.common::TokenStandardVersion(V1|V2) andTokenClientConfig.version, whichTokenClientdispatches on at each of ten methods.crates/token/src/utils.rshold the version-independent steps, so each V2 entry point writes only what differs: the registry URL, the choice arguments, and the exercise command.token::active_contracts::Params.account: Option<Account>— holding selection can now filter on the account label a V2 holding carries in the metadata of its V1 interface view.Caveats
registry::post_jsonmakes up to three attempts, and only when no answer arrived: a refused connection, a timeout, or a request the client could not build. It never retries a response, whatever the status. A 4xx or a 5xx is the registry's own answer, and repeating the call would hide it. Each attempt also carries a 30-second timeout, where before there was none, so a hung connection stalled the caller forever. The retries are immediate, because the failure is a dropped connection to a load-balanced host where the next attempt reaches another backend; a backoff would need a timer this crate does not carry. This is a behaviour change for every consumer of the four transfer-path routes, not only for the tests.--ignoredjob, the next regression in those ten operations is found by whoever next remembers to run it. Treat that job as the registry-acceptance check rather than the safety net: it needs credentials and it breaks when devnet moves, as it just did. #40 is the part that can run on every push.wiremock0.6, dev-only, intokenandregistry. Justified because no unit test can otherwise observe a version dispatch, or a retry — the two things this PR adds — without mocking our own modules, which the repository's testing rules forbid. It stubs the HTTP boundary of a third-party service, which those rules do allow. It is the standard Rust HTTP-stub crate, actively maintained, and a[dev-dependencies]entry, so it never ships in a consumer's build.crates/token:cbtc-libv0.5.0,canton-vaultv0.3.1,cbtc-faucetv0.2.0 anddlc-attestor-stackv0.6.0, all importing onlycommon,registry,ledgerandkeycloak. Each adopts this change when someone bumps its own tag, and the change is additive at that moment too —cbtc-libonly constructsChoiceArgumentsVariationsvariants, at twelve sites, and nothing in any consumer matches on that enum exhaustively.TokenClientConfiggained a required field with no default. This is deliberate:versionhas no struct-level default, so everyTokenClientConfigliteral must name it and no existing caller can silently change version. It is a compile error for a consumer, on the tag bump, not a behaviour change.read_account_fordispatches on the version like the other ten call sites, returningNoneon V1, so every V1 caller is untouched. Without the filterutxo_countandcheck_and_consolidatecounted different sets, andholdingsreturned contract ids thatsplitwould refuse.Account.owner: Nonerule below for why the restriction decides it.consolidate_utxos, and re-raised the double active-contract-set read it had missed in round one.fb19355fixes both and the thread is resolved. Round two generated no new comments, so the two rounds converged. Review effort level wasLiteand it read 26 of the 27 changed files.Details
V1 and V2 side by side
A caller picks a version per client. No V1 item moved: each V2 item sits in a
v2submodule beside its V1 twin. The tables below are the whole difference between the two paths.The wire types —
commonTransfer.sender,.receiverStringAccount { owner: Option<String>, provider: Option<String>, id: String }expectedAdmin,transfer,extraArgstransfer,actors,extraArgsextraArgs, forAcceptonlyactors,extraArgs, for all three choicesInstrumentId,Meta,DisclosedContract,ExtraArgs,Context,ContextValue,Response,ChoiceContextAccount::basic(owner)builds the plain case: no provider, empty id.ownerandproviderserialize as explicit nulls, because the Daml JSON encoding ofOptionalexpects the field to be present.The routes —
registry…/transfer-instruction/v1/transfer-factory…/transfer-instruction/v2/transfer-factory…/transfer-instruction/v1/{cid}/choice-contexts/accept…/transfer-instruction/v2/{cid}/choice-contexts/{choice}Both sit under the same registrar prefix,
/api/token-standard/v0/registrars/{admin}/registry/. V1 has one accept-context function, and the crate has no V1 reject or withdraw route (#33). V2 serves all three contexts under one path shape, so one function and anInstructionChoiceenum cover them.All three V2 context routes were probed on devnet before implementation began, against two controls: each answered exactly as its V1 twin, while a deliberately absent
v9route returns 404. The factory route needed no probe, because a full CIP-112 transfer had already settled through it on devnet — update id12207d9d…, offset 3974978, on registry-app 0.9.0.The ledger command —
token#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:…#splice-api-token-transfer-instruction-v2:Splice.Api.Token.TransferInstructionV2:…TransferFactory_Transfer,TransferInstruction_Accept,_Reject,_WithdrawactorsChoiceArgumentsVariationsTransferFactory,AcceptTransferFactoryV2,AcceptV2Every entry point, field by field —
tokenEach V2
Paramsmirrors its V1 twin and changes only these fields. The last column is what stayed a bare party string on purpose.transfer::Paramstransfer: Transfertransfer: v2::Transfertransfer::Recipientreceiver: Stringreceiver: Accounttransfer::SequentialChainedParamssender: Stringsender: Accountsplit::Paramsparty: Stringaccount: Accountconsolidate::ConsolidateParamsparty: Stringaccount: Accountconsolidate::CheckConsolidateParamsparty: Stringaccount: Accountdistribute::Recipientreceiver: Stringreceiver: Accountdistribute::Paramssender: Stringsender: Accountbatch::Paramssender: Stringsender: Accountreceiver,amountaccept::Paramsreceiver_party: Stringaccept::AcceptAllParamsreceiver_party: Stringreject::Paramsreceiver_party: Stringcancel_offers::Paramssender_party: Stringcancel_offers::WithdrawBatchParamssender_party: Stringcancel_offers::WithdrawAllParamssender_party: Stringconsolidate::GetUtxoCountParamsaccount: Option<Account>party: StringDecisions 2 and 3 in the table below settle the last column. Accept, reject and cancel act on an instruction that already fixes both sides, and the registry checks a party set rather than an account, so an
Accountthere would carry a provider and an id that nothing reads.batch::v2::parse_recipientslifts each CSV row withAccount::basic, so an operator's existing file still loads.Review changed two rows of that table. The three instruction params first renamed
transfer_offer_contract_idtotransfer_instruction_id, and V2 declared its own copy of five structs. The contract id carries the same value under both versions, so V2 now re-exports the V1 types and keeps the V1 field name. That removed five public structs this branch had added and collapsed fiveTokenClientdispatch arms.consolidate::GetUtxoCountParamsis the one existing type this branch breaks.get_utxo_countnow serves both versions, so it needs the account, and the struct has noDefault. PassNoneto get the pre-0.7.0 behaviour. Issue #45 proposes deriving the party fromaccount.ownerand dropping thepartyfield, which would remove the field again.The client —
token::clientTokenClientConfigversionfield before 0.7.0version: TokenStandardVersion, with no defaultsend,accept,accept_all,reject,cancel_offer,cancel_all_offers,consolidate,check_and_consolidate,split,distributeactive_contracts::Params.accountNone, so every holding the party ownsSome(basic(party)), so only that account's holdingsThe
Account.owner: NoneruleThis is the only input a V2 entry point rejects that V1 had no way to express.
HoldingV2.daml:27-32reservesowner: Nonefor accounts the instrument admin manages, and names a mint source and a burn target as the examples. This library is not the instrument admin, so it neither operates such an account nor targets one.Every V2 entry point guards every account it holds, through one shared
require_ownerhelper incrates/token/src/utils.rs, failing with an error that names the offending field:That includes both sides of a transfer. The guard costs this library the ability to transfer into a burn target, and there is no burn path to lose: burns delegate to an external
BurnMintFactoryand never reach the transfer factory.The two guards do not share a justification. The one on
submit's receiver enforces the restriction above.submit_sequential_chainedguards each recipient because it reads the receiver's owner, forTransferResultand forgenerate_unique_reference.V1 carried bare party strings, which cannot be absent, so no V1 caller can reach this error.
The eleven decisions of 1 Sep 2026, two of which reverse the spec
The pilot settled eleven choices before implementation. The first four are what spec section 12 listed as proposed but unconfirmed; decisions 1 and 2 reverse the spec, and the plan's table is the record where the two disagree.
actors; do not add it to any publicParams.accept,rejectandcancel_offerskeep their party field in V2.withdraw_batchkeepssender_partytoo.receiver,amount.require_ownerhelper guards every entry point holding anAccount.split::v2::submit.v2::withdraw_allmirrors V1 rather than delegating towithdraw_batch.TokenClient's version dispatch is tested against a local HTTP stub.submit_sequential_chainedgets avalidatefunction, so its entry checks have tests.-D warningsgate is achievable.Submissionsites, including the one insidewithdraw_all's batch loop.active_contracts::Paramsgainsaccount.On decision 1: the registry's
checkActorscompares by set equality and accepts exactly oneactorsset per path, each a function of data the entry point already holds — so any other value fails the submission and there is nothing for a caller to choose. Verified in the registry's own Daml rather than inferred:TransferFactory_Transferaccepts{sender.owner}atAllocationFactory.daml:774, andTransferInstruction_Accept,_Rejectand_Withdrawaccept{receiver},{receiver}and{sender}atTransfers.daml:154,167,180.Notes
ChoiceArgumentsVariationsis untagged, so its variant order is load-bearing. A test now pins the first claim of the ordering comment: movingAllocationFactoryafterAcceptfails, where before that reorder passed the whole suite and silently turned every allocation payload into anAcceptwith its allocation dropped.Conversions.daml:115,188,191,369), the derivedactorssets,actAsandreadAsacross all twenty-onebuild_submissionsites, and all ten dispatch arms.v2submodules should not be promoted. Leaving the V2 items atv2::means the V1 paths stop resolving and the compiler reports a missing item rather than a mismatched field.crates/registry/src/transfer_factory.rsgainedintegration_transfer_factory_v2, mirroring the existing V1 test with V2 types and noexpectedAdmin.Cargo.tomlandCargo.lockcarry the workspace version 0.6.1 → 0.7.0. The lock was updated bycargo check --workspace, not regenerated.crates/registry/src/lib.rsrather than a newhttpmodule, because this crate has five modules and one shared helper does not earn a sixth. It coverstransfer_factory::getand::v2::get, andaccept_context::getand::v2::get.allocation_contextandallocation_factorykeep their own clients, because no allocation code calls them today.Follow-ups, filed as issues. The plan asked for these before merge.
cancel_offersV1 fetches the accept context for a withdrawregistrycrate has no V1 reject or withdraw routeMetaValueis an empty struct across ten fileswithdraw_batchhas no integration test in either versiontransfer::MultiParamshas no consumerTokenState::newsets its expiry in the pastconsolidate_utxosV1 does not guard inputs it cannot priceAcceptV2partyfromaccount.ownerinactive_contracts::get#46— fixed here inconsolidate_utxosqueries the active-contract set twicefb19355, issue closed#44 is the one worth reading. A V2 factory payload whose choice context carries a tag
ContextValuedoes not list parses asAcceptV2, silently dropping the wholetransferfield. That was measured, not reasoned. Nothing in this repository deserializes the enum at runtime, so it is latent, and the existing tests miss it because they all use an empty context.Added after review began:
a0f46aetests the instrument admin guard.An instrument is identified by two things, its
idand itsadmin. Anyone can issue a token whoseidisCBTC, so both must be compared. Two commits on this branch established that:5997f9dadded the admin comparison, and20e6e14made theidcomparison exact. Neither shipped a test, and5997f9d's own message names the hazard it prevents — "without it holdings of a same-ticker token from another admin would be selected as inputs and fail atexpectedAdmin".The gap was measurable. Every case in
active_contracts::label_testsheld the admin fixed atadmin::1220efand varied only theid, so it exercised one conjunct of two.utilshad no test at all: the same comparison sat inline in a closure insidefetch_transfers, which opens a websocket and cannot be reached by a unit test.Measured on 8 Sep 2026, before this commit: deleting
&& admin == instrument.adminfromactive_contracts.rsand the same conjunct fromutils.rsleft 91 of 91 tests green. The guard could be removed by a future refactor without a single test noticing.This commit adds:
wanted_transfer, extracted fromfetch_transfersfor the reasonactive_contracts::wantedalready exists — the rules move somewhere a unit test can reach them. The extraction is behaviour-preserving, and the suite stayed at 89 passed before any test was added.wanted_transfer: a matching offer is wanted, a same-ticker offer under another admin is not, and an offer in the wrong direction is not. The third exists so a filter that ignoreddirectionentirely could not pass the first two.wanted: a same-ticker holding under another admin is not wanted, holdingidfixed and varyingadmin, which is the conjunct the existing case did not reach.Verified by the mutation check the repository's own convention asks for: with the admin conjunct deleted from both filters, exactly the two new admin tests fail and 91 pre-existing tests pass; restored, all 93 pass and
clippy -D warningsis clean.Why the offer filter is the one that matters. A
TransferOfferinutility-registry-app-v00.9.1 (Utility/Registry/App/V0/Model/Transfer.daml:33-34) readssignatory provider, transfer.instrumentId.admin, transfer.senderandobserver operator, transfer.receiver. The receiver is an observer and consents to nothing, and all three signatories belong to a party running its own registry. So a registrar can create a same-ticker offer addressed to any holder. A holding cannot be planted the same way —Utility/Registry/Holding/V0/Holding.daml:42keepsownera signatory. The admin comparison inutilsis therefore what closes the reachable path, and it was the untested one.Closed on this branch rather than filed: the branch-coverage half of
withdraw_batch's tests, the pre-existing clippy lint, the duplicate metadata literals inclient.rsand the test helper, the label-blind V2 reads, and the double active-contract-set read inconsolidate_utxos(#46).Design spec:
docs/specs/2026-08-28-canton-lib-token-standard-v2-design.md· Implementation plan:docs/plans/2026-09-01-canton-lib-token-standard-v2.md(where the two disagree, the plan wins). Both live in thecip-112repository.