fix: USDCx registry — match DA hosted token-standard API shape#276
Conversation
The Splice transfer-factory endpoint hosted by DA's utilities API
(api.utilities.digitalasset-dev.com) mounts the registry per-registrar
under /api/token-standard/v0/registrars/{registrar}/, and expects the
on-ledger choice arguments wrapped in `choiceArguments` with a structured
`instrumentId` plus `executeBefore`/`requestedAt`/`extraArgs` fields.
The SDK was emitting the legacy unprefixed path with a flat body, so
transfers against the deployed devnet failed with `registry returned
404` from nginx. The local devstack happened to register the legacy
path, masking the bug. Update both the SDK client and the devstack
sidecar to the spec-conformant shape, and reuse the registry call's
requestedAt/executeBefore in the on-ledger TransferFactory_Transfer
choice so the timestamps stay consistent.
Codecov Report❌ Patch coverage is
❌ Your patch status has failed because the patch coverage (0.00%) is below the target coverage (50.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #276 +/- ##
=======================================
Coverage ? 30.61%
=======================================
Files ? 130
Lines ? 10056
Branches ? 0
=======================================
Hits ? 3079
Misses ? 6710
Partials ? 267
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request updates the Canton SDK and registry client to align with the Splice token-standard API specification. The changes include restructuring the RegistryRequest JSON payload to wrap choice arguments, introducing structured InstrumentRef and AnyValueMap types, and updating registry URLs to include a registrar party ID segment. Additionally, the logic now ensures that RequestedAt and ExecuteBefore timestamps are synchronized between registry calls and on-ledger choice exercises. Feedback was provided to URL-escape the registrarParty segment in the registry URL to correctly handle party IDs containing special characters like colons.
|
DA's hosted API returns: The SDK's RegistryResponse struct (unchanged in this PR) expects: After this PR, when the SDK talks to DA:
I hit exactly this error a few days ago when I tried to patched the SDK to send 1 USDCx from devnet_usdcx , i think same probe data as this PR's verification table. The local mock at scripts/setup/usdcx-registry.go:queryFactory (lines unchanged in this PR) still returns the legacy flat shape: so the local devstack e2e passes after this PR (mock + SDK both speak legacy-flat shape) while DA's hosted registry remains broken at decode time. I think the unchecked "Confirm end-to-end USDCx transfer succeeds on deployed devnet" test plan item in PR is exactly where this will surface. suggested fix and update scripts/setup/usdcx-registry.go:queryFactory to nest disclosedContracts inside choiceContext so the mock matches DA's spec exactly: (Drop the top-level DisclosedContracts field on the mock's struct, or leave it as omitempty.) This is exactly what I did int the fix when I sent 1 USDCx from devnet_usdcx::…f0c to test parties. Looks like that diff was rolled into the indexer-fix branch or just Part of the scripts PR but not back-ported into the SDK |
DA's hosted token-standard registry returns the AnyValue choice context
and disclosed contracts both nested inside `choiceContext` (mirroring
the receiver-side AcceptContextResponse shape):
{ "factoryId": ..., "transferKind": ...,
"choiceContext": { "choiceContextData": {"values": {...}},
"disclosedContracts": [...] } }
The previous PR fixed the request side but left the SDK's RegistryResponse
expecting a flat shape (choiceContext = AnyValue map directly,
disclosedContracts at top-level). Against DA's response that would put
the whole {choiceContextData, disclosedContracts} object into
ChoiceContext and leave DisclosedContracts null; ConvertAnyValueChoiceContext
finds no `values` key at that level, falls through to ConvertChoiceContext,
which fails with "json: cannot unmarshal object into Go value of type string".
GetTransferFactory now decodes into a wire struct and lifts choiceContextData
and disclosedContracts back into the flat RegistryResponse the downstream
converters expect. The local devstack mock is updated to emit the same
nested shape, so it actually exercises the unwrap path.
Also URL-escape the registrar party in the request path so future party IDs
with reserved characters don't silently misroute (Gemini's review).
Diagnosis and fix by salindne.
|
@salindne — verified, you are 100% right and this is a separate bug from the request-side fix I landed first. Walking the code on
Sealed by the symmetry check: Pushed
All unit tests + golangci-lint clean. Sorry I missed it on the first pass — the request-side 404 hid the response-side decode failure, so my probes never got past schema validation. |
Co-authored-by: Sebastian Lindner <33971232+salindne@users.noreply.github.com>
Summary
registry returned 404from nginx.api.utilities.digitalasset-dev.com) mounts the registry per-registrar at/api/token-standard/v0/registrars/{registrar}/registry/transfer-instruction/v1/transfer-factoryand expects the on-ledger choice arguments wrapped inchoiceArgumentswith structuredinstrumentId, plusexecuteBefore/requestedAt/extraArgsfields. The SDK was sending the legacy unprefixed path with a flat body, which the local devstack happened to accept but DA's API rejects.usdcx-registrydevstack sidecar to the spec-conformant shape, and reuses the registry call'srequestedAt/executeBeforein the on-ledgerTransferFactory_Transferchoice so the timestamps stay consistent.Symptom
Verification against DA's dev API
Direct probes against
api.utilities.digitalasset-dev.com:/registry/transfer-instruction/v1/transfer-factory404nginx (matches user-reported error)/api/token-standard/v0/registrars/<party>/registry/transfer-instruction/v1/transfer-factorywith flat body422 "Missing required field: DownField(choiceArguments)"instrumentId+extraArgs+executeBefore/requestedAt400 "No holdings provided"(real handler, schema accepted, only blocked by probe's empty CIDs)What changed
pkg/cantonsdk/token/registry_client.goregistryPathconstant withregistryPathFmtmirroringacceptContextPathFmt.RegistryRequestnow wraps everything inChoiceArguments+ExcludeDebugFields;RegistryTransferDetail.InstrumentIDis now a structuredInstrumentRef{Admin,ID}; addedMeta,ExecuteBefore,RequestedAt, andExtraArgs{Context,Meta}fields withAnyValueMapenvelopes.GetTransferFactorynow takes the registrar party and formats it into the URL.pkg/cantonsdk/token/client.gotransferFactoryRequestcarriesRequestedAt/ExecuteBefore.resolveTransferFactorypopulates them before the registry call;buildTransferCommandreuses them so the registry and the on-ledger choice see identical timestamps (falling back to recomputation for any path that bypasses resolve).scripts/setup/usdcx-registry.go(local devstack sidecar)routeRegistrarprefix handler that validates the registrar in the URL path and dispatches to the transfer-factory or accept-context handler by suffix.Test plan
go build ./...go build scripts/setup/usdcx-registry.gogo test ./...(21 packages, all green)./bin/golangci-lint run./bin/golangci-lint run --build-tags e2e ./tests/e2e/...make test-e2e-apiagainst local devstack to confirm the sidecar still serves USDCx flows on the new per-registrar URL