ChainSafe / ChainSafe/canton-middleware
[Epic] USDCx transfer to external (non-registered) parties via party id
- Dominant language
- Go
- Stars
- 1
- Forks
- 1
- Avg merge
- 40m
- Merged PRs (30d)
- 1
Description
# Goal
Enable the middleware to transfer USDCx (and any Splice token) **out** to a party that is **not registered in the middleware** — i.e. a party hosted on an external participant node — addressed purely by its **Canton party id**. This is the outbound counterpart to the inbound accept flow we already ship, and the concrete breakdown of the original request in #250.
# Current state
- We can transfer USDCx between parties **inside** our participant node (custodial and non-custodial).
- We can **receive** USDCx from external participants (find pending `TransferOffer`s + `AcceptTransferInstruction`, #260/#261/#262).
- We **cannot send** USDCx to an external party, because `POST /api/v2/transfer/prepare` resolves the recipient via `userStore.GetUserByEVMAddress(req.To)` (`pkg/transfer/service.go:148`) — the recipient must be a locally-registered user with an EVM address.
# Key finding: the SDK is already done
The Canton SDK already supports sending to an arbitrary party id; only the API/service layer forces a local recipient:
- `token.Client.PrepareTransfer` / `TransferByPartyID` take a raw `ToPartyID` and never require it to be local.
- `resolveTransferFactory` already routes USDCx through Circle's registry (keys off `InstrumentAdmin`, independent of the recipient).
- `buildTransferCommand` puts the recipient into the `TransferFactory_Transfer` choice as `transfer.receiver`; `ReadAs` is nil for external tokens (visibility via disclosed contracts).
So the work is mostly a thin API/service layer + authorization + status visibility + tests + docs.
# Important semantic
Outbound is **2-phase and asynchronous**: exercising `TransferFactory_Transfer` creates a `TransferInstruction`/`TransferOffer` and locks the sender's holdings. The **external recipient must accept on their own node** — we cannot accept for them. "Success" from our API = offer submitted, **not** settled. Unaccepted offers expire at `executeBefore` and unlock (expiry/claim-back handled by the #290/#291/#292 trilogy).
# Child issues
- [ ] #316 — feat(api): non-custodial transfer to external party via party id **(core)**
- [ ] #317 — feat(api): custodial transfer to external party via party id
- [ ] #318 — feat: outbound transfer authorization & whitelist policy
- [ ] #319 — test(e2e): outbound USDCx transfer to external participant party
- [ ] #320 — docs: external-party outbound transfer (registry config + runbook + API)
# Dependencies & related (existing issues)
- #250 — original request; **superseded by this epic** (recommend closing once #316 lands).
- #290 — SDK: per-call offer validity. Needed so external recipients get a sensible accept window (default is currently hardcoded to +1h). Related; not blocking for a first cut.
- #291 — indexer: persist `expires_at` + `TransferInstruction` CID + status filters. Foundation for outbound status visibility and claim-back.
- #292 — API: `GET /api/v2/transfer/outgoing` + `TransferInstruction_Withdraw` (claim-back) + validity on prepare. **Provides the sender-side status/recourse for offers that external parties never accept** — the natural status layer for this epic. We rely on it rather than duplicating an outgoing-list here.
- #275 — `prepared_transaction` envelope on prepare endpoints (signing UX); applies to the new external prepare too.
- #272 — transaction history API for non-custodial users (visibility, related).
- #219 / #216 — USDCx transfer test coverage (related).
- #199 — parent "[Epic] Support USDCx".
# Open questions (resolve in #318)
1. Whitelist-gate outbound on the sender's EVM address? (Recommend yes.)
2. Allow custodial outbound, or restrict to non-custodial (user-signed) only?
3. Offer validity window for external recipients (depends on #290).
# Proposed sequence of work
**Phase 1 — core path**
1. #318 — decide + implement the authorization policy (small; unblocks the rest).
2. #316 — non-custodial `external/prepare` + `execute`, party-id validation, recipient guards. Headline deliverable; depends on no new SDK work.
**Phase 2 — breadth + status (parallelisable)**
3. #317 — custodial single-call outbound.
4. #290 → #291 → #292 — validity → indexer expiry/CID → `/outgoing` + claim-back. Run this track in parallel; it gives senders visibility and a way to reclaim funds when an external party never accepts.
**Phase 3 — hardening**
5. #319 — e2e coverage for outbound to an external party.
6. #320 — docs, config reference, operator runbook.
7. (#275 envelope, #272 history) — fold in opportunistically.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start at pkg/transfer/service.go:148 and compare the local EVM-address lookup with token.Client.PrepareTransfer and TransferByPartyID. Read child issues #316–#320 and the listed dependencies to separate the core API, authorization, status, end-to-end test, and documentation work. The epic is done when those outbound external-party paths and their tests and docs are complete.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api, authorization, backend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100