Skip to content

Add Token Standard V2 to the transfer path - #31

Merged
gyorgybalazsi merged 31 commits into
mainfrom
feature/token-standard-v2
Sep 9, 2026
Merged

gyorgybalazsi merged 31 commits into
mainfrom
feature/token-standard-v2

Conversation

@gyorgybalazsi

@gyorgybalazsi gyorgybalazsi commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Old behavior

canton-lib spoke 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 v2 submodule 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, and consolidate::GetUtxoCountParams gained a required account field. Two things a caller can now do that it could not before:

  • Set TokenClientConfig.version to TokenStandardVersion::V2 and have every operation on that client — send, accept, reject, cancel, distribute, split, consolidate, batch — go to the V2 routes.
  • Address a labelled account rather than a bare party. V2 carries sender and receiver as an 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 --workspacePASS, exit 0. 170 passed, 0 failed, 39 ignored at the head commit a0f46ae. The 39 are 33 ignored unit tests and 6 ignored doctests. This read 160 when first written, at 38d81ca; the branch has gained ten passing tests since, four of them in a0f46ae.
  • cargo clippy --workspace --all-targets -- -D warningsclean, exit 0. No warnings.
  • cargo fmt --checkclean, exit 0.

origin/main measures 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 test therefore 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 _v2 twins plus integration_transfer_factory_v2. The branch also renames the ten existing integration tests with a _v1 suffix, 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 ```ignore examples on accept_context::get and on three consolidate functions. Each example names a placeholder host and a placeholder access token, so running it would make a network call. rustdoc neither compiles nor runs them, on this branch or on main.

The integration suite ran against devnet at the head commit 38d81ca, and it is green: 27 of 27 pass.

cargo test -p registry -p token --no-fail-fast -- --ignored --test-threads=1 integration_

registry:  test result: ok.  2 passed; 0 failed; finished in   5.12s
token:     test result: ok. 25 passed; 0 failed; finished in 795.75s

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 38d81ca responds to: the registry POST had no retry and no timeout, so one dropped connection failed a test outright. Two clean runs have followed, at 09cf0e8 and 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:

Operation V1 V2
registry::transfer_factory ok ok
transfer_offer_accept ok ok
transfer_accept_all ok ok
transfer_offer_cancel_reject ok ok
cancel_all ok ok
distribute ok ok
split ok ok
split_total ok ok
check_and_consolidate ok ok
batch_from_csv ok ok

| utxo_count | ok | ok |
| active_contracts::get_by_party | ok | ok |

The last two rows are new in 09cf0e8 and both passed on their first devnet run.

Every other ignored test is version-independent and needs no twin: the three credentials tests, amulet_rules, mining_rounds, ledger_end, the two websocket tests, and the ledger crate's own get_by_party, which takes no instrument and no account.

integration_transfer_factory_v2 is 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, consolidate and check_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 the ledger_end::get that 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::submit and cancel_offers::v2::submit are each proved to send their own choice — the regression they exclude is #32 on the V2 path. v2::withdraw_batch is covered the same way, across all four of its branches, because TokenClient cannot 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_2 and KEYCLOAK_PASSWORD_2 now supply party 2's credentials, each falling back to its party-1 twin. The defect predates this branch: client_for already did this at 074f41b. The environment also needs several variable overrides, which #39 records.

The change that enables it

  • A v2 submodule beside each V1 module: common::{transfer, transfer_factory, accept}::v2, registry::{transfer_factory, accept_context}::v2, and token::{transfer, split, consolidate, accept, reject, cancel_offers, distribute, batch}::v2.
  • common::TokenStandardVersion (V1 | V2) and TokenClientConfig.version, which TokenClient dispatches on at each of ten methods.
  • Six shared helpers in crates/token/src/utils.rs hold 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

  • A registry POST now retries, and the limits of that matter. registry::post_json makes 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.
  • Nothing schedules the devnet run. It is green today because a person ran it by hand, with the environment overrides Verification lists. Until #35 adds a scheduled --ignored job, 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.
  • New dependency: wiremock 0.6, dev-only, in token and registry. 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.
  • Nothing downstream recompiles until someone bumps a tag. Every consumer pins canton-lib by git tag, and none of them depends on crates/token: cbtc-lib v0.5.0, canton-vault v0.3.1, cbtc-faucet v0.2.0 and dlc-attestor-stack v0.6.0, all importing only common, registry, ledger and keycloak. Each adopts this change when someone bumps its own tag, and the change is additive at that moment too — cbtc-lib only constructs ChoiceArgumentsVariations variants, at twelve sites, and nothing in any consumer matches on that enum exhaustively.
  • TokenClientConfig gained a required field with no default. This is deliberate: version has no struct-level default, so every TokenClientConfig literal 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.
  • A V2 client's reads now filter by its own account. read_account_for dispatches on the version like the other ten call sites, returning None on V1, so every V1 caller is untouched. Without the filter utxo_count and check_and_consolidate counted different sets, and holdings returned contract ids that split would refuse.
  • A V2 transfer now rejects a receiver whose account has no owner. That input previously reached the registry. See the Account.owner: None rule below for why the restriction decides it.
  • The repository has no CI, so the unit numbers above come from a local run and there is no green check to point at (#35).
  • Copilot reviewed twice and both rounds returned "needs a closer look", asking for final human review because the V2 API surface is large. It raised one inline finding, a quadratic membership scan in consolidate_utxos, and re-raised the double active-contract-set read it had missed in round one. fb19355 fixes both and the thread is resolved. Round two generated no new comments, so the two rounds converged. Review effort level was Lite and 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 v2 submodule beside its V1 twin. The tables below are the whole difference between the two paths.

The wire types — common

V1 V2
Transfer.sender, .receiver String Account { owner: Option<String>, provider: Option<String>, id: String }
Factory choice arguments expectedAdmin, transfer, extraArgs transfer, actors, extraArgs
Instruction choice arguments extraArgs, for Accept only actors, extraArgs, for all three choices
Reused unchanged InstrumentId, Meta, DisclosedContract, ExtraArgs, Context, ContextValue, Response, ChoiceContext

Account::basic(owner) builds the plain case: no provider, empty id. owner and provider serialize as explicit nulls, because the Daml JSON encoding of Optional expects the field to be present.

The routes — registry

V1 V2
Transfer factory …/transfer-instruction/v1/transfer-factory …/transfer-instruction/v2/transfer-factory
Choice context …/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 an InstructionChoice enum 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 v9 route returns 404. The factory route needed no probe, because a full CIP-112 transfer had already settled through it on devnet — update id 12207d9d…, offset 3974978, on registry-app 0.9.0.

The ledger command — token

V1 V2
Interface id #splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:… #splice-api-token-transfer-instruction-v2:Splice.Api.Token.TransferInstructionV2:…
Choice names TransferFactory_Transfer, TransferInstruction_Accept, _Reject, _Withdraw identical
actors absent derived per choice, never a parameter
ChoiceArgumentsVariations TransferFactory, Accept adds TransferFactoryV2, AcceptV2

Every entry point, field by field — token

Each V2 Params mirrors its V1 twin and changes only these fields. The last column is what stayed a bare party string on purpose.

Module V1 field V2 field Kept as a party
transfer::Params transfer: Transfer transfer: v2::Transfer
transfer::Recipient receiver: String receiver: Account
transfer::SequentialChainedParams sender: String sender: Account
split::Params party: String account: Account
consolidate::ConsolidateParams party: String account: Account
consolidate::CheckConsolidateParams party: String account: Account
distribute::Recipient receiver: String receiver: Account
distribute::Params sender: String sender: Account
batch::Params sender: String sender: Account CSV rows stay receiver,amount
accept::Params unchanged V2 reuses the V1 type receiver_party: String
accept::AcceptAllParams unchanged V2 reuses the V1 type receiver_party: String
reject::Params unchanged V2 reuses the V1 type receiver_party: String
cancel_offers::Params unchanged V2 reuses the V1 type sender_party: String
cancel_offers::WithdrawBatchParams unchanged unchanged sender_party: String
cancel_offers::WithdrawAllParams unchanged V2 reuses the V1 type sender_party: String
consolidate::GetUtxoCountParams gained account: Option<Account> same type serves both party: String

Decisions 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 Account there would carry a provider and an id that nothing reads. batch::v2::parse_recipients lifts each CSV row with Account::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_id to transfer_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 five TokenClient dispatch arms.

consolidate::GetUtxoCountParams is the one existing type this branch breaks. get_utxo_count now serves both versions, so it needs the account, and the struct has no Default. Pass None to get the pre-0.7.0 behaviour. Issue #45 proposes deriving the party from account.owner and dropping the party field, which would remove the field again.

The client — token::client

V1 V2
TokenClientConfig no version field before 0.7.0 version: TokenStandardVersion, with no default
Dispatching methods ten: send, accept, accept_all, reject, cancel_offer, cancel_all_offers, consolidate, check_and_consolidate, split, distribute
active_contracts::Params.account None, so every holding the party owns Some(basic(party)), so only that account's holdings

The Account.owner: None rule

This is the only input a V2 entry point rejects that V1 had no way to express. HoldingV2.daml:27-32 reserves owner: None for 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_owner helper in crates/token/src/utils.rs, failing with an error that names the offending field:

transfer.sender.owner is None: this library cannot operate a registry-managed account

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 BurnMintFactory and 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_chained guards each recipient because it reads the receiver's owner, for TransferResult and for generate_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.

# Decision Effect on the spec
1 Derive actors; do not add it to any public Params. Reverses 7.3.2, and drops 7.3.1's last table row.
2 accept, reject and cancel_offers keep their party field in V2. Reverses 7.3.1 rows 4 and 5. withdraw_batch keeps sender_party too.
3 The batch CSV keeps bare receiver parties; columns stay receiver,amount. Confirms 7.3.3.
4 One shared require_owner helper guards every entry point holding an Account. Widens 7.3.5 to include split::v2::submit.
5 v2::withdraw_all mirrors V1 rather than delegating to withdraw_batch. Beyond the spec.
6 TokenClient's version dispatch is tested against a local HTTP stub. Beyond the spec.
7 submit_sequential_chained gets a validate function, so its entry checks have tests. Beyond the spec.
8 The per-leg recipient guard ships with no unit test. Beyond the spec; an accept.
9 Clear the 17 pre-existing clippy lints first. Beyond the spec. Without it no -D warnings gate is achievable.
10 Extract all ten Submission sites, including the one inside withdraw_all's batch loop. Beyond the spec.
11 Holding selection learns the account label: active_contracts::Params gains account. Beyond the spec.

On decision 1: the registry's checkActors compares by set equality and accepts exactly one actors set 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_Transfer accepts {sender.owner} at AllocationFactory.daml:774, and TransferInstruction_Accept, _Reject and _Withdraw accept {receiver}, {receiver} and {sender} at Transfers.daml:154,167,180.

Notes

  • ChoiceArgumentsVariations is untagged, so its variant order is load-bearing. A test now pins the first claim of the ordering comment: moving AllocationFactory after Accept fails, where before that reorder passed the whole suite and silently turned every allocation payload into an Accept with its allocation dropped.
  • These claims were checked against the registry's Daml and need no re-checking: the account-label metadata keys (Conversions.daml:115,188,191,369), the derived actors sets, actAs and readAs across all twenty-one build_submission sites, and all ten dispatch arms.
  • At the eventual cutover, the v2 submodules should not be promoted. Leaving the V2 items at v2:: means the V1 paths stop resolving and the compiler reports a missing item rather than a mismatched field.
  • crates/registry/src/transfer_factory.rs gained integration_transfer_factory_v2, mirroring the existing V1 test with V2 types and no expectedAdmin.
  • Cargo.toml and Cargo.lock carry the workspace version 0.6.1 → 0.7.0. The lock was updated by cargo check --workspace, not regenerated.
  • The retry lives in crates/registry/src/lib.rs rather than a new http module, because this crate has five modules and one shared helper does not earn a sixth. It covers transfer_factory::get and ::v2::get, and accept_context::get and ::v2::get. allocation_context and allocation_factory keep their own clients, because no allocation code calls them today.

Follow-ups, filed as issues. The plan asked for these before merge.

Issue What
#32 cancel_offers V1 fetches the accept context for a withdraw
#33 The registry crate has no V1 reject or withdraw route
#34 MetaValue is an empty struct across ten files
#35 No CI, and a scheduled job is needed for the ignored tests
#36 withdraw_batch has no integration test in either version
#37 transfer::MultiParams has no consumer
#38 TokenState::new sets its expiry in the past
#39 The integration-test environment is missing variables
#40 Cover the five uncovered dispatch arms with a ledger stub
#41 Extract the V1 CSV recipient parsing and test it
#42 consolidate_utxos V1 does not guard inputs it cannot price
#43 Decide whether a withdraw batch needs one context per instruction
#44 An unknown context tag makes a V2 factory payload parse as AcceptV2
#45 Derive party from account.owner in active_contracts::get
#46 consolidate_utxos queries the active-contract set twice — fixed here in fb19355, issue closed

#44 is the one worth reading. A V2 factory payload whose choice context carries a tag ContextValue does not list parses as AcceptV2, silently dropping the whole transfer field. 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: a0f46ae tests the instrument admin guard.

An instrument is identified by two things, its id and its admin. Anyone can issue a token whose id is CBTC, so both must be compared. Two commits on this branch established that: 5997f9d added the admin comparison, and 20e6e14 made the id comparison exact. Neither shipped a test, and 5997f9d'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 at expectedAdmin".

The gap was measurable. Every case in active_contracts::label_tests held the admin fixed at admin::1220ef and varied only the id, so it exercised one conjunct of two. utils had no test at all: the same comparison sat inline in a closure inside fetch_transfers, which opens a websocket and cannot be reached by a unit test.

Measured on 8 Sep 2026, before this commit: deleting && admin == instrument.admin from active_contracts.rs and the same conjunct from utils.rs left 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 from fetch_transfers for the reason active_contracts::wanted already 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.
  • Three tests on 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 ignored direction entirely could not pass the first two.
  • One test on wanted: a same-ticker holding under another admin is not wanted, holding id fixed and varying admin, 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 warnings is clean.

Why the offer filter is the one that matters. A TransferOffer in utility-registry-app-v0 0.9.1 (Utility/Registry/App/V0/Model/Transfer.daml:33-34) reads signatory provider, transfer.instrumentId.admin, transfer.sender and observer 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:42 keeps owner a signatory. The admin comparison in utils is 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 in client.rs and the test helper, the label-blind V2 reads, and the double active-contract-set read in consolidate_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 the cip-112 repository.

gyorgybalazsi and others added 12 commits September 1, 2026 21:52
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>
@gyorgybalazsi
gyorgybalazsi requested review from a team and sosaucily September 1, 2026 22:09
@socket-security

socket-security Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedwiremock@​0.6.59610093100100

View full report

gyorgybalazsi and others added 3 commits September 2, 2026 00:30
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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::TokenStandardVersion and adds TokenClientConfig.version, with TokenClient dispatching 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 via active_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.

Comment thread crates/token/src/consolidate.rs
@gyorgybalazsi

Copy link
Copy Markdown
Contributor Author

Persona review — disposition summary

Four 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, 63ed81b…bb7555b.

Persona Findings Fixed Fixed w/ mods Filed Won't-fix
PM-against-spec 2 2 0 0 0
Pragmatic 8 5 0 3 0
Testing 2 1 1 1 0
Reviewer's own pass 4 2 0 2 0
Total 16 10 1 6 0

By category

Category Findings Fixed Fixed w/ mods Filed
Security 2 1 0 1
Functionality 6 4 1 2
Cosmetic 8 5 0 3
Total 16 10 1 6

The three that mattered

  • A V2 client read every account label while spending only its basic account. So utxo_count() and check_and_consolidate() counted different sets. This description had deferred the fix, arguing that account-aware reads would change what a V1 caller sees. That was wrong, because the client already dispatches on the version at ten call sites. Fixed in bb7555b.
  • v2::withdraw_batch shipped as new public API with four branches and no test. TokenClient cannot reach it, so the devnet tests do not either. Fixed in 4dd73af. Rewriting its retry guard as !batch.is_empty() now fails a test on the submission count.
  • An #[allow(dead_code)] sat on require_owner under a comment saying it had no callers. It had seven. The suppression would have hidden the guard becoming unwired, which is the one failure of that helper that would cost anything. Removed in bb30cbc.

Verified against the Daml, and left alone

  • The account-label metadata keys are correct. tokenStandardV2Namespace is "cip-112/" and the holding view uses field "account", so the keys are cip-112/account.provider and cip-112/account.id. Each is written only when set, which is what matches_account assumes on both branches.
  • The derived actors sets are correct for this registry: checkActors actors [[transfer.sender]] at AllocationFactory.daml:774, and [[receiver]], [[receiver]] and [[sender]] for accept, reject and withdraw at Transfers.daml:154,167,180. The V1 sender that check compares against is the account's owner, so [sender.owner] holds even for a provider-carrying account.
  • All ten dispatch arms route to the matching version.

Filed rather than fixed

Issue Why not here
#33 Routing reject.rs's inline V1 URL through the registry crate needs a new public function there.
#40 Covering the five uncovered dispatch arms needs a websocket stub and a new dev-dependency.
#41 Extracting the V1 CSV parser changes V1 code this PR otherwise leaves alone.
#42 Adding V2's input guard to V1 changes V1 behaviour.
#43 Whether a withdraw batch needs one context per instruction is a question for the registry's owners.
#44 Closing the untagged mis-parse changes a shared public type.

#44 is the security finding and it is worth reading. A V2 factory payload whose choice context carries a tag ContextValue does not list parses as AcceptV2, silently dropping the whole transfer field. I measured that rather than reasoned it. 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.

Two gaps in this review, stated plainly

  • The Copilot pass did not run. Requesting copilot-pull-request-reviewer[bot] returns success, but Copilot never appears in requested_reviewers and never posts a review. Copilot code review appears not to be enabled for this repository. So this PR has no non-Claude reviewer.
  • The security persona returned no report. Two other personas returned truncated reports and their tails never arrived. I covered the security lens myself, including the untagged-enum pair analysis that produced #44, but a fresh security pass would still be worth having before merge.

Gate status. cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace all pass on bb7555b, at 145 passed and 0 failed. The integration suite has still not passed against this code, and a separate devnet run is in progress. The merge call stays with a human.

gyorgybalazsi and others added 4 commits September 2, 2026 09:50
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>
@gyorgybalazsi

Copy link
Copy Markdown
Contributor Author

Persona review — round 2, and the loop's outcome

Round 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 cip-112/* key exists on any holding view in either environment — 688,571 rows on devnet and 115,971,661 on mainnet, all carrying only utility.digitalasset.com/holding-label. It then checked the Daml and withdrew the finding: that measurement is equally explained by a correct implementation on a ledger with no labelled holdings yet, which is what this is. The keys are right. Worth recording because the retraction is the useful part — a live query that finds nothing is not evidence of a wrong key name.

Its verdict on the rest: nothing blocking. actAs and readAs are correct across all twenty-one build_submission sites, no credential reaches a log or an error, no V1 path changed behaviour, and nothing in the diff tries to steer a reviewing agent.

Round 2 findings and dispositions

Finding Lens Disposition
transfer::v2::submit guards the sender account but not the receiver Security Fixed, a4173f4
Nothing proves the three V2 instruction operations send their own CHOICE Testing Fixed, 4c7e391
The enum-ordering comment's first claim has no test Testing Fixed, 79625d2
matches_account ignores account.owner; party and account are independent Security Filed #45
consolidate_utxos queries the active-contract set twice Security Filed #46
The cross-account input guard has no test Testing Filed on #40
v2::accept_all and v2::withdraw_all have no batching or counting cover Testing Filed on #40
active_contracts::get's account filter is tested as a helper, never as wiring Testing Filed on #40

The finding worth reading twice

Nothing tested that accept::v2::submit, reject::v2::submit and cancel_offers::v2::submit pass their own choice constant. The existing tests build the command themselves, so they pin the constant's value and not that the shipped function reads it.

That distinction is not academic. Pointing reject::v2::submit at accept's constant reintroduces #32 on the V2 path: it fetches the reject context, then exercises TransferInstruction_Accept against it. Under exactly that edit the old test passed and the new one failed on the choice name.

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 !batch.is_empty(), read_account_for's two arms flipped, reject::v2::submit pointed at accept's constant, and AllocationFactory moved after Accept.

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>
@gyorgybalazsi

Copy link
Copy Markdown
Contributor Author

Review loop closed — devnet green on the reviewed code

The devnet suite re-ran after the review commits. 25 of 25 pass, zero failures.

registry:   2 passed; 0 failed;   9.60s
token:     23 passed; 0 failed; 666.59s

Run against 79625d2. The head commit e786145 changes no runtime code, confirmed by hashing the bytes above the first #[cfg(test)] in each changed file: three files are byte-identical and client.rs differs only by the utxo_count doc comment. So this run is evidence for the head commit. The two are named separately rather than conflated.

The run completed rather than stopping early, and that was checked rather than assumed. All 25 test lines end ... ok, no line ends in a bare ..., the log ends with its final doctest summary, and the 23 token tests account for the full expected set. A first pass grepping for failure signatures returned four hits, all of them the 0 failed inside a test result: ok line.

a4173f4, the account guard on a transfer's receiver, rejected nothing. That was predicted before the run from the code rather than after it from the result: every owner: None construction in the workspace sits in a unit-test module, and every integration and production receiver goes through Account::basic, which always sets an owner.

Where the review ended

Commits added by the review 5 of the 17 on this branch
Unit gate at e786145 152 passed, 0 failed, 31 ignored
origin/main baseline 88 passed, measured, not assumed
Regressions injected to prove a test bites 6, all caught
Blocking findings open 0
Issues filed 15, #32#46

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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_utxos calls active_contracts::get twice (once to derive input_holding_cids and 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 build input_holding_cids (when absent) and to compute holdings/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_cids when 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 priced holdings.
                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 hubagaspar91 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CHANGELOG.md
Comment thread crates/registry/src/accept_context.rs
Comment thread crates/registry/src/lib.rs
Comment thread crates/token/src/transfer.rs
Comment thread crates/token/src/client.rs Outdated
Comment thread crates/token/src/client.rs
Comment thread crates/token/src/transfer.rs
Comment thread crates/token/src/accept.rs Outdated
Comment thread crates/token/src/batch.rs Outdated
Comment thread crates/token/src/consolidate.rs
gyorgybalazsi and others added 7 commits September 7, 2026 15:15
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>
scolear
scolear previously approved these changes Sep 9, 2026
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>
@gyorgybalazsi
gyorgybalazsi merged commit 2805997 into main Sep 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants