Skip to content

feat(core,react): add the custody client - #202

Open
TaprootFreak wants to merge 4 commits into
developfrom
feat/custody-client
Open

feat(core,react): add the custody client#202
TaprootFreak wants to merge 4 commits into
developfrom
feat/custody-client

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Step 4 of #198: move the customer-facing custody contract into the packages.

Changes

@dfx.swiss/core

  • CustodyUrl covering signup, balance, history, order, order confirm, PDF report, the account list
    and per-account balance / history / order / PDF, plus the access grants
  • Types for all of it: CustodyAccount, CustodyBalance, CustodyAssetBalance, CustodyFiatValue,
    CustodyHistory, CustodyOrder, CustodyOrderPaymentInfo, CustodyOrderHistory,
    CreateCustodyOrder, CustodyAccountAccess and friends, plus the enums (CustodyOrderType,
    CustodyOrderStatus, CustodyOrderHistoryStatus, CustodyAccessLevel, CustodyAddressType,
    CustodyValueCurrency)
  • CustodyApi, wired into DfxApiClient as client.custody

@dfx.swiss/react

  • useCustody() with the same surface
  • Re-exports of the types

Tests cover signup, account-scoped vs. own-account reads, the legacy account, order creation and
confirmation, the PDF query and the access grants.

What the API actually says

Verifying against the API turned up four differences from a locally modelled version of this
contract:

  1. isOwner is part of the account. The API states outright whether the caller owns an account
    or was granted access to it. Deriving that from the owner id is guesswork that the response
    already answers.
  2. A balance can carry interest and interestValue for interest-bearing positions. Both are
    already folded into balance / value — a breakdown, never an amount to add on top. Typing them
    away hides the breakdown; adding them up double-counts.
  3. History entries carry a Date, not a string.
  4. created is always present on an order history entry, so it is typed as required.

The legacy account has a URL of its own. The API accepts legacy as an account id
(custody/account/legacy/balance), so the legacy account does not have to be routed to the plain
endpoints. Both forms are available here: LegacyCustodyAccountId for the account-scoped path, and
getBalance / getHistory / getOrders for the caller's own account.

Scope

  • Order calls take an explicit token. Creating and confirming an order needs the custody token
    from signup, not the session token. Both methods take it as a parameter so the caller can hold
    both tokens side by side.
  • Not moved: the Safe screen state — loading flags, request generations, account selection,
    deposit/withdraw asset pairing, PDF download to disk. That is UI orchestration, not the wire
    contract.
  • Staff paths stay out. custody/admin/* (order list, approve, balance correction) is not part
    of this client.

Additive only — no existing export changes shape.

Version fields, changelogs and lockfile pins are untouched, per CONTRIBUTING.


Corrections from review

  • createOrder and confirmOrder now require the custody token. It used to be optional, and the
    HTTP client falls back to the session token when no token is passed — so the one call that must not
    use the session token would have used it, and failed against the API instead of failing to compile.
  • CreateCustodyOrder is a union over the order type. The API requires a destination address and
    blockchain for a send, an IBAN for a withdrawal, and exactly one of the two amounts. All of that was
    modelled as independently optional fields, which typechecks a request the API rejects.
  • Account management and access grants take a persisted id. PUT custody/account/:id,
    GET :id/access, PUT :id/access/:accessId and DELETE :id/access/:accessId do not accept the
    legacy marker; only the reading endpoints and POST :id/access do. PersistedCustodyAccountId
    keeps the two apart.
  • toCustodyAccountId(account) closes the null-id gap. A legacy account has id: null; passing
    that straight through would request custody/account/null/….
  • moderator is dropped from CustodySignup. The API validates it against an enum of internal
    values; a customer-facing SDK should neither invent that set nor freeze it.

Not changed, with reasons:

  • error?: TransactionError mirrors what the package already does for the same API field elsewhere
    (Buy.error), even though the API's own enum has drifted apart from it. Fixing that is a
    package-wide change, not a custody one.
  • Beneficiary.name is required here while the API has it optional — that is the existing shared
    type, untouched by this change.

Later passes

  • Only five order types can be requested. CustodyOrderService.createOrder has cases for
    DEPOSIT, WITHDRAWAL, SWAP, SEND and RECEIVE and no default; the four equity and saving
    types fall through, leave paymentInfo null and produce a 500 on the next line. Those types do
    appear in the history, so the enum keeps all nine — CreatableCustodyOrderType names the five that
    can be asked for.
  • CustodyOrderPaymentInfo.id is optional. A swap on a configured equity pair is priced without a
    transaction request of its own and comes back with id: undefined.
  • grantAccess is the one access call that also takes the legacy marker — the controller resolves it
    there but not when reading, changing or revoking a grant. That asymmetry is now stated at both call
    sites instead of being left to be rediscovered.

@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Took 4 review passes to reach a clean run — the most of the five, and the ones that found the most.

What the passes turned up, in order:

  1. createOrder and confirmOrder took the custody token as optional. The HTTP client falls back to
    the session token when none is passed, so the one call that must not use it would have — and failed
    against the API rather than at compile time. Also: CreateCustodyOrder modelled the type-dependent
    fields as independently optional, moderator exposed an internal enum, and the account management
    and access endpoints do not accept the legacy marker the reading endpoints do.
  2. Two URL builders produced paths identical to two existing ones.
  3. The order service only handles five of the nine order types. EQUITY_MINT, EQUITY_REDEEM,
    SAVING_DEPOSIT and SAVING_WITHDRAWAL have no case and no default — paymentInfo stays null and
    the next line throws a 500. They stay in the enum (the history reports them) but can no longer be
    requested. In the same pass: CustodyOrderPaymentInfo.id had to become optional, because an
    equity-paired swap is priced without a transaction request and answers with id: undefined.
  4. Clean.

toCustodyAccountId(account) was added along the way — a legacy account has id: null, and passing
that through would have requested custody/account/null/….

@TaprootFreak
TaprootFreak marked this pull request as ready for review August 2, 2026 16:31
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.

1 participant