ChainSafe / ChainSafe/canton-middleware

[Epic] USDCx transfer to external (non-registered) parties via party id

Open
#321 1 comment 0 reactions 0 assignees View on GitHub
Type: Epic
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.