Skip to content

fix: USDCx registry — match DA hosted token-standard API shape#276

Merged
sadiq1971 merged 4 commits into
mainfrom
fix/usdcx-registry-da-spec
May 20, 2026
Merged

fix: USDCx registry — match DA hosted token-standard API shape#276
sadiq1971 merged 4 commits into
mainfrom
fix/usdcx-registry-da-spec

Conversation

@sadiq1971
Copy link
Copy Markdown
Member

Summary

  • Fixes USDCx custodial transfers on deployed devnet, which failed with registry returned 404 from nginx.
  • The Splice transfer-factory endpoint hosted by DA's utilities API (e.g. api.utilities.digitalasset-dev.com) mounts the registry per-registrar at /api/token-standard/v0/registrars/{registrar}/registry/transfer-instruction/v1/transfer-factory and expects the on-ledger choice arguments wrapped in choiceArguments with structured instrumentId, plus executeBefore/requestedAt/extraArgs fields. The SDK was sending the legacy unprefixed path with a flat body, which the local devstack happened to accept but DA's API rejects.
  • Updates both the SDK registry client and the local usdcx-registry devstack sidecar to the spec-conformant shape, and reuses the registry call's requestedAt/executeBefore in the on-ledger TransferFactory_Transfer choice so the timestamps stay consistent.

Symptom

MetaMask - RPC Error: RPC submit: canton transfer failed:
  registry lookup for decentralized-usdc-interchain-rep::1220d420…:
  registry returned 404: <html>… nginx/1.28.1 …</html>

Verification against DA's dev API

Direct probes against api.utilities.digitalasset-dev.com:

Request Response
Old SDK URL /registry/transfer-instruction/v1/transfer-factory 404 nginx (matches user-reported error)
New URL /api/token-standard/v0/registrars/<party>/registry/transfer-instruction/v1/transfer-factory with flat body 422 "Missing required field: DownField(choiceArguments)"
Wrapped body with structured instrumentId + extraArgs + executeBefore/requestedAt 400 "No holdings provided" (real handler, schema accepted, only blocked by probe's empty CIDs)

What changed

  • pkg/cantonsdk/token/registry_client.go
    • Replaced registryPath constant with registryPathFmt mirroring acceptContextPathFmt.
    • RegistryRequest now wraps everything in ChoiceArguments + ExcludeDebugFields; RegistryTransferDetail.InstrumentID is now a structured InstrumentRef{Admin,ID}; added Meta, ExecuteBefore, RequestedAt, and ExtraArgs{Context,Meta} fields with AnyValueMap envelopes.
    • GetTransferFactory now takes the registrar party and formats it into the URL.
  • pkg/cantonsdk/token/client.go
    • transferFactoryRequest carries RequestedAt / ExecuteBefore. resolveTransferFactory populates them before the registry call; buildTransferCommand reuses 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)
    • Mirrored the new request shape.
    • Replaced the two separate path registrations with a single routeRegistrar prefix 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.go
  • go test ./... (21 packages, all green)
  • ./bin/golangci-lint run
  • ./bin/golangci-lint run --build-tags e2e ./tests/e2e/...
  • Verified SDK now produces a request DA's API parses (probe past schema validation; only blocked downstream on probe's fake holding CID)
  • Confirm end-to-end USDCx transfer succeeds on deployed devnet after this is rolled out
  • Re-run make test-e2e-api against local devstack to confirm the sidecar still serves USDCx flows on the new per-registrar URL

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-commenter
Copy link
Copy Markdown

codecov-commenter commented May 19, 2026

Codecov Report

❌ Patch coverage is 0% with 42 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@64306b5). Learn more about missing BASE report.

Files with missing lines Patch % Lines
pkg/cantonsdk/token/client.go 0.00% 27 Missing ⚠️
pkg/cantonsdk/token/registry_client.go 0.00% 15 Missing ⚠️

❌ 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

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #276   +/-   ##
=======================================
  Coverage        ?   30.61%           
=======================================
  Files           ?      130           
  Lines           ?    10056           
  Branches        ?        0           
=======================================
  Hits            ?     3079           
  Misses          ?     6710           
  Partials        ?      267           
Flag Coverage Δ
unittests 30.61% <0.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
pkg/cantonsdk/token/registry_client.go 0.00% <0.00%> (ø)
pkg/cantonsdk/token/client.go 0.00% <0.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

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.

Comment thread pkg/cantonsdk/token/registry_client.go Outdated
@sadiq1971 sadiq1971 requested a review from salindne May 20, 2026 10:47
@salindne
Copy link
Copy Markdown
Contributor

salindne commented May 20, 2026

DA's hosted API returns:

{
  "factoryId": "...",
  "transferKind": "offer",
  "choiceContext": {
    "choiceContextData": { "values": { ... AnyValue map ... } },
    "disclosedContracts": [ ... ]
  }
}

The SDK's RegistryResponse struct (unchanged in this PR) expects:

type RegistryResponse struct {
    FactoryID          string          `json:"factoryId"`
    TransferKind       string          `json:"transferKind"`
    ChoiceContext      json.RawMessage `json:"choiceContext"`         // expects to BE the AnyValue map
    DisclosedContracts json.RawMessage `json:"disclosedContracts"`    // expects top-level
}

After this PR, when the SDK talks to DA:

  • ChoiceContext captures the whole {choiceContextData, disclosedContracts} blob, not the AnyValue map
  • DisclosedContracts ends up null (no top-level field in DA's response)
  • ConvertAnyValueChoiceContext(regResp.ChoiceContext) sees no values key, returns empty AcceptChoiceContext
  • Falls through to ConvertChoiceContext, which fails with json: cannot unmarshal object into Go value of type string

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:

return &RegistryResponse{
    FactoryID:    factoryCID,
    TransferKind: "transfer",
    ChoiceContext: map[string]any{          // ← directly the AnyValue map, no choiceContextData wrapper
        "values": map[string]any{...},
    },
    DisclosedContracts: []DisclosedContract{...},  // ← top-level, not nested
}

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
In GetTransferFactory, unwrap the nested shape on parse and update the mock to emit the same shape:

// In registry_client.go GetTransferFactory, replace the current Unmarshal:
var wireResp struct {
    FactoryID     string          `json:"factoryId"`
    TransferKind  string          `json:"transferKind"`
    ChoiceContext json.RawMessage `json:"choiceContext"`
}
if err := json.Unmarshal(respBody, &wireResp); err != nil {
    return nil, fmt.Errorf("parse registry response: %w", err)
}
var inner struct {
    ChoiceContextData  json.RawMessage `json:"choiceContextData"`
    DisclosedContracts json.RawMessage `json:"disclosedContracts"`
}
if len(wireResp.ChoiceContext) > 0 && string(wireResp.ChoiceContext) != "null" {
    if err := json.Unmarshal(wireResp.ChoiceContext, &inner); err != nil {
        return nil, fmt.Errorf("parse choiceContext wrapper: %w", err)
    }
}
return &RegistryResponse{
    FactoryID:          wireResp.FactoryID,
    TransferKind:       wireResp.TransferKind,
    ChoiceContext:      inner.ChoiceContextData,
    DisclosedContracts: inner.DisclosedContracts,
}, nil

and update scripts/setup/usdcx-registry.go:queryFactory to nest disclosedContracts inside choiceContext so the mock matches DA's spec exactly:

return &RegistryResponse{
    FactoryID:    factoryCID,
    TransferKind: "transfer",
    ChoiceContext: map[string]any{
        "choiceContextData": map[string]any{
            "values": map[string]any{...},
        },
        "disclosedContracts": []DisclosedContract{...},
    },
}

(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.
@sadiq1971
Copy link
Copy Markdown
Member Author

@salindne — verified, you are 100% right and this is a separate bug from the request-side fix I landed first. Walking the code on be45575:

  1. RegistryResponse.ChoiceContext (raw) ends up holding DA's whole {choiceContextData, disclosedContracts} envelope, not the AnyValue map.
  2. RegistryResponse.DisclosedContracts ends up null (no top-level field in DA's response).
  3. ConvertAnyValueChoiceContext looks for a top-level values key in that envelope, finds none, returns empty AcceptChoiceContext.
  4. Fall-through hits ConvertChoiceContextjson.Unmarshal(envelope, &map[string]string) → fails with json: cannot unmarshal object into Go value of type string. Exactly the symptom you described.

Sealed by the symmetry check: AcceptContextResponse in pkg/cantonsdk/token/types.go:218 already uses the nested choiceContextData / disclosedContracts envelope for the accept endpoint — so DA is consistently nesting on both endpoints, and our transfer-factory parser was the lone outlier.

Pushed dd8ff68 applying your suggested fix:

  • GetTransferFactory decodes into a wire struct, then lifts choiceContext.choiceContextDataRegistryResponse.ChoiceContext and choiceContext.disclosedContractsRegistryResponse.DisclosedContracts. Null choiceContext leaves both empty (converters short-circuit on that).
  • Local devstack scripts/setup/usdcx-registry.go now emits the same nested shape, so the mock actually exercises the new unwrap path (would have masked the bug otherwise, as you flagged).
  • Also picked up @gemini-code-assist's suggestion to url.PathEscape(registrarParty) defensively.

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.

Comment thread pkg/cantonsdk/token/client.go
sadiq1971 and others added 2 commits May 20, 2026 19:49
Co-authored-by: Sebastian Lindner <33971232+salindne@users.noreply.github.com>
@sadiq1971 sadiq1971 merged commit 95a07a7 into main May 20, 2026
3 checks passed
@sadiq1971 sadiq1971 deleted the fix/usdcx-registry-da-spec branch May 20, 2026 14:23
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.

3 participants