Skip to content

feat(protocols): add SDA (Smart Deposit Address) protocol interface#46

Open
AlonzoRicardo wants to merge 26 commits into
mainfrom
feat/sda-protocol
Open

feat(protocols): add SDA (Smart Deposit Address) protocol interface#46
AlonzoRicardo wants to merge 26 commits into
mainfrom
feat/sda-protocol

Conversation

@AlonzoRicardo

@AlonzoRicardo AlonzoRicardo commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the generic SdaProtocol interface for Smart Deposit Address (SDA) providers — services that issue a deposit address, accept a stablecoin (or native token) from a supported source chain, convert it, and deliver a chosen asset (e.g. USDT) to a chosen destination chain and address. This is the foundation that providers (Rhino, Orchestra/Flashnet, Relay, Candide, …) implement as wdk-protocol-sda-* modules and that TW consumes for the "Other Assets" receive flow.

Part 1 of 3 (the interface — the bulk of the work), mirroring the earlier swidge work.

What's in it

src/protocols/sda-protocol.jsISdaProtocol (the interface) + abstract SdaProtocol, re-exported from @tetherto/wdk-wallet/protocols.

  • Required core (every provider implements): getSupportedRoutes, createDepositAddress.
  • Optional, capability-gated: quoteDeposit, deriveDepositAddress, getDepositAddress, renewDepositAddress, getTransferStatus, getDepositAddressTransfers, getTransfersByRecipient, recoverDepositAddress, disableDepositAddress.
  • Introspection: getCapabilities() returns a descriptor (custody model, client-derivability, activation lifecycle, quoting, reusability, route discovery, recovery, history-by-address/recipient, status, disable, refund) so consumers adapt to provider differences without trial-and-error.

Design notes

  • New sda category, not swidge. Swidge executes an on-chain swap+bridge from the user's wallet (returns {hash, fee}); SDA issues a deposit address the user funds externally and the provider converts/delivers. Different lifecycle and return shape → its own category (closest sibling is fiat).
  • Capability-gating over a fat interface. Optional methods default to throwing NotImplementedError; getCapabilities() advertises what's real.
  • Custody + activation as first-class capabilities (from Candide's review). custodyModel distinguishes operator-held addresses from on-chain self-custodial ones; clientDerivableAddress flags deterministically-derivable addresses; activation (none / required / ttl) captures the address lifecycle. recovery is scoped to provider-side missed-deposit reprocessing only — keep-alive of an expired address is activation: 'ttl'.
  • Provider-agnostic derivation. Rather than hard-code any provider's tuple, self-custodial derivation inputs are a generic, untyped derivation payload (passed through, echoed back on SdaDepositAddress), with deriveDepositAddress (pure client-side) separated from createDepositAddress (which also activates).

Validation

Pressure-tested against four structurally different providers spanning both custody models (local throwaway modules, not part of this PR). The Candide reference is validated live against Candide's alpha forwarding API (forwarding_getAddress / account_activateForwardingAddress / forwarding_getStatus). Every optional method is implemented by ≥1 provider and skipped by ≥1, and every capability takes multiple values:

Rhino Orchestra Relay Candide
custodyModel trusted-operator trusted-operator trusted-operator self-custodial
clientDerivableAddress
activation ttl none none ttl
createDepositAddress 1 family address N per-chain 1 quote-bound derive + activate
deriveDepositAddress
renewDepositAddress
quoteRequired (uses quote)
getTransferStatus
getDepositAddress
getDepositAddressTransfers
getTransfersByRecipient
recovery none reindex reindex none
disableDepositAddress

Each provider also passes a full registerProtocol → getSdaProtocol → method round-trip via the WDK.

Notes

  • Source-only; generated .d.ts are produced by npm run build:types.
  • Draft pending the P002 design-spec sign-off. Non-blocking refinements still open for the spec: limits denomination (USD vs token base-unit), history time-range filter, metadata/webhook fields.

Related PRs

This is one of a 3-PR set:


Add the generic `SdaProtocol` interface for "Smart Deposit Address"
providers — services that issue a deposit address, accept a stablecoin
(or native token) from a supported source chain, convert it, and deliver
a chosen asset (e.g. USDT) to a chosen destination chain and address.

- New `src/protocols/sda-protocol.js`: `ISdaProtocol` + abstract
  `SdaProtocol`, with normalized types (route, deposit address, quote,
  transfer) and a `getCapabilities()` descriptor so consumers can adapt
  to provider differences without trial-and-error.
- Required core every provider implements: `getSupportedRoutes`,
  `createDepositAddress`. Everything else (quote, get-address, status,
  history, recovery, disable) is optional and capability-gated.
- Re-exported from `@tetherto/wdk-wallet/protocols`.

Part 1 of 3 (interface). Companion PRs: getSdaProtocol wiring in
tetherto/wdk and the `sda` module type in tetherto/create-wdk-module.

Generated `.d.ts` are produced by `npm run build:types`.
@AlonzoRicardo AlonzoRicardo marked this pull request as ready for review June 24, 2026 14:43
AlonzoRicardo added a commit to tetherto/wdk that referenced this pull request Jun 24, 2026
The published @tetherto/wdk-wallet does not yet export SdaProtocol, so CI
cannot resolve the import and the test suites fail to load. Point the
dependency at the wdk-wallet feat/sda-protocol branch so CI is green and
the wiring is reviewable.

REVERT BEFORE MERGE: bump @tetherto/wdk-wallet back to a published version
that includes SdaProtocol once tetherto/wdk-wallet#46 is merged and released.
@Sednaoui

Copy link
Copy Markdown

great addition @AlonzoRicardo

I'm commenting from the angle of Candide's Forwarding Address. I think this is solid and overall very well written from a capabilities standpoint. A few things I think are missing or worth a fit.

Currently, capabilities don't say who holds the funds. Relay, Rhino, and Orchestra are all operator controlled. This means the deposit address is the provider's, and recovery means "ask their backend to reindex/reactivate." A self-custodial version is a different architecture: the address is an onchain smart contract with withdrawal rights fixed in code. In our case the recipient can withdraw immediately, and a custodial withdrawer (an optional recovery wallet) can do so only after a timelock. So funds are recoverable onchain without the provider, and no single operator can unilaterally move them. Since all three reference providers (Relay, Rhino, and Orchestra) sit on the operator side, nothing in the interface currently forces the distinction. I can propose to add something along these lines to cover the self-custodial, trust minimized design:

/** @typedef {'self-custodial' | 'trusted-operator'} SdaCustodyModel */

// SdaCapabilities:

* @property {SdaCustodyModel} custodyModel - Who controls deposited funds in flight.
* @property {boolean} clientDerivableAddress - Whether the address is deterministically derivable/verifiable client-side, before any provider call.
  • On the word "recovery.": Here it means the provider re-processing a missed deposit (reindex/reactivate). For us those are two separate things: reactivating an expired TTL is just keep alive (what we call "reactivate"), whereas "recovery" specifically means the onchain withdrawal of stuck funds described above: recipient first, custodial withdrawer after the timelock. This recovery isn't a recoverDepositAddress call at all, it's the custody guarantee, which is the other reason the custodyModel flag is important. Keeping the two vocabularies distinct would avoid a consumer assuming recovery: 'none' means "funds can't be recovered."

  • History is keyed by deposit address, not recipient: getDepositAddressTransfers keys on the deposit address, so a consumer with several addresses per user (multichain, or multiple salts) has to track each one and fan out a call per address. We expose status by recipient + destination chain instead: it aggregates every deposit, across all deposit addresses and source chains, routing to that user. A historyByRecipient capability + an optional getTransfersByRecipient(recipient, destinationChain, options) (or letting the history query accept a destination address) would cover it.

Two gaps in SdaCreateOptions for a deterministic provider like ours:

  • The custodial withdrawer is an input to the CREATE2 address, so it can't live in static config, it changes the address. refundAddress is different (push-refund style). A generic optional withdrawer/recoveryParty that derivation can consume would cover it.
  • No caller salt key: we need it to rederive a specific address (one-per-user vs one-per-tx)

Happy to send the custodyModel interface as a proposal if it's useful

If I missed any other tought or feedback @sherifahmed990 please feel free to further add it

…nterface

Address Candide's review on #46 (their self-custodial Forwarding Address):

- `custodyModel` ('self-custodial' | 'trusted-operator') and
  `clientDerivableAddress` capabilities — distinguish operator-held from
  on-chain self-custodial deposit addresses.
- Clarify that `recovery` is provider-side missed-deposit reprocessing,
  NOT fund custody; `recovery: 'none'` no longer implies funds are
  unrecoverable (that's `custodyModel`).
- Optional `getTransfersByRecipient` + `historyByRecipient` capability for
  providers that aggregate status by recipient across many addresses.
- Optional `withdrawer` and `salt` on `SdaCreateOptions` (inputs to a
  deterministic CREATE2 address derivation).

Validated against a 4th reference provider (self-custodial, client-derivable).
@AlonzoRicardo

Copy link
Copy Markdown
Contributor Author

Thanks @Sednaoui — I've pushed an update to this PR that folds in your feedback:

Added SdaCustodyModel = 'self-custodial' | 'trusted-operator', plus custodyModel and clientDerivableAddress on SdaCapabilities — essentially your proposal.

recovery is now documented strictly as provider-side reprocessing of a missed deposit (reindex/reactivate), explicitly not a statement about fund recoverability — that's custodyModel. So recovery: 'none' + custodyModel: 'self-custodial' reads correctly: "no provider reprocess call, funds recoverable on-chain." Your TTL keep-alive maps to reactivate; the on-chain stuck-fund withdrawal is the custody guarantee, not a recoverDepositAddress call.

History by recipient. Added optional getTransfersByRecipient(recipient, destinationChain, options) + a historyByRecipient capability, alongside the existing address-keyed one.

Create options. Added optional withdrawer and salt to SdaCreateOptions, documented as inputs to the (CREATE2) derivation — distinct from refundAddress, and salt for one-per-user vs one-per-tx.

Yes please, I'd love the custodyModel proposal. We're writing a short design spec for the interface before it merges, and the custody axis belongs in it as a first-class dimension. Two things that'd help:

  • The exact derivation inputs you'd want the interface to carry — is recipient + withdrawer + salt enough, or is there a factory / init-code / timelock parameter a consumer should pass or read back?
  • Whether you'd want getDepositAddress to support re-deriving/verifying an address client-side (vs a backend lookup) — a nice property unique to the derivable model.

cc @sherifahmed990

@sherifahmed990

Copy link
Copy Markdown
  • the derivation inputs are recipient, custodialWithdrawer, destinationChainId and salt.
  • other params like the factory, singelton address and initcode abi can be included as constants in the address calculation implementation.
  • getDepositAddress verifying the result returned from the api is a good idea.

@Sednaoui

Sednaoui commented Jul 1, 2026

Copy link
Copy Markdown

To build on Sherif’s comment, I propose WDK interface to not hardcode Candide’s derivation tuple as the universal model. Other self-custodial, destination specific providers may encode different parameters in their deposit address, depending on their recovery model, execution model, supported inputs, or routing design.

To keep this generic, I can suggest writing the common capabilities, while leaving derivation inputs provider specific, like the following:

Capabilities

  • custodyModel: 'self-custodial' | 'trusted-operator'
  • clientDerivableAddress: boolean
  • activation: 'none' | 'required' | 'ttl'

Methods

  • createDepositAddress: returns an address that is ready to receive deposits according to the provider’s activation model. For activation: 'ttl', this may include activation/registration as part of the call.
  • getDepositAddress: existing lookup
  • optional pure deriveDepositAddress(inputs) -> address for clientDerivableAddress providers. This does not activate/register/monitor the address. Verification is derive + compare.
  • optional renewDepositAddress: refreshes activation for providers where activation: 'ttl'.

Data

  • derivation inputs should be a provider-specific payload that the generic layer passes through without typing.
  • the created SdaDepositAddress should echo that payload back, so the address can be rederived, verified, or recovered later.

For Candide, the derivation payload is:

{
  recipient,
  custodialWithdrawer,
  destinationChainId,
  salt?
}

One nuance: for Candide, client derivation is mainly used as a verification, fallback, and recovery property. The normal flow is still to call forwarding_getAddress, optionally verify the returned address, then call account_activateforwardingaddress so the relayer monitors it. Deriving the address alone does not activate forwarding.

…erface

Follow-up to Candide's review (#46): generalise self-custodial support so
the interface does not hard-code any provider's derivation tuple.

- New `activation: 'none' | 'required' | 'ttl'` capability (address
  lifecycle). `recovery` no longer carries 'reactivate' — keep-alive of an
  expired address is now `activation: 'ttl'`.
- Replace the typed `withdrawer`/`salt` create options with a generic,
  provider-specific `derivation` payload, echoed back on `SdaDepositAddress`
  so an address can be re-derived / verified / recovered later.
- New optional `deriveDepositAddress` (pure client-side derivation) and
  `renewDepositAddress` (refresh a `ttl` activation).
- `createDepositAddress` documented as returning a ready-to-receive address
  (activating it where the model requires).

Validated with a live self-custodial reference (Candide Forwarding Address).
@AlonzoRicardo

Copy link
Copy Markdown
Contributor Author

Thanks @Sednaoui — I've pushed an update to this PR that folds in your feedback:

Added SdaCustodyModel = 'self-custodial' | 'trusted-operator', plus custodyModel and clientDerivableAddress on SdaCapabilities — essentially your proposal.

recovery is now documented strictly as provider-side reprocessing of a missed deposit (reindex/reactivate), explicitly not a statement about fund recoverability — that's custodyModel. So recovery: 'none' + custodyModel: 'self-custodial' reads correctly: "no provider reprocess call, funds recoverable on-chain." Your TTL keep-alive maps to reactivate; the on-chain stuck-fund withdrawal is the custody guarantee, not a recoverDepositAddress call.

History by recipient. Added optional getTransfersByRecipient(recipient, destinationChain, options) + a historyByRecipient capability, alongside the existing address-keyed one.

Create options. Added optional withdrawer and salt to SdaCreateOptions, documented as inputs to the (CREATE2) derivation — distinct from refundAddress, and salt for one-per-user vs one-per-tx.

Yes please, I'd love the custodyModel proposal. We're writing a short design spec for the interface before it merges, and the custody axis belongs in it as a first-class dimension. Two things that'd help:

  • The exact derivation inputs you'd want the interface to carry — is recipient + withdrawer + salt enough, or is there a factory / init-code / timelock parameter a consumer should pass or read back?
  • Whether you'd want getDepositAddress to support re-deriving/verifying an address client-side (vs a backend lookup) — a nice property unique to the derivable model.

cc @sherifahmed990

Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js
Comment thread src/protocols/sda-protocol.js
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/index.js
Replace `string | number` on every chain field with a shared `Blockchain`
type alias (PR #46 review). Also commits the generated SDA `.d.ts`
(previously missing) — unrelated pre-existing type drift in other
protocols' declarations is left untouched.
Widen SdaLimits.min/max to `number | bigint` (PR #46 review).
The id keys status/recovery/disable operations, so a created address must
always carry one (PR #46 review).
…sOptions

Offset-based pagination (`skip`) is consistent with the wallet modules'
get-transfers methods (PR #46 review).
Shorter, matches the wallet modules' naming (PR #46 review). Coexists with
getTransfersByRecipient (by-address vs by-recipient history).
SDA has no config fields all providers share, so the base takes only the
account; each provider declares its own config and constructor (PR #46
review).
Provider-specific derivation inputs belong on the provider's own options /
result type (extend + cast), not a generic Record field on the shared type
(PR #46 review / #39). Removed `derivation` from SdaCreateOptions and
SdaDepositAddress.
UnsupportedOperationError is thrown by optional operations a concrete
implementation deliberately doesn't support (distinct from a NotImplementedError
abstract stub). AccountRequiredError is thrown when an operation needs a wallet
account but none was bound at construction. Both support the SDA protocol's
capability-by-behavior model (PR #46 review).
…ed capability

Remove the `getCapabilities()` method and the `SdaCapabilities` typedef.
Capability is now discovered by behavior: an optional operation a provider
doesn't support inherits the base and throws UnsupportedOperationError, and the
descriptive traits (custody / activation / route-discovery models) are documented
on each provider rather than queried. Also drop the `reusableAddresses` trait —
reusable vs single-use is a per-request choice via `SdaCreateOptions.reusable`,
not a static protocol trait (PR #46 review; Jonathan/Davide).
createDepositAddress and deriveDepositAddress resolve the delivery destination
from the bound account when `destinationAddress` is omitted; document that they
throw AccountRequiredError when neither is available, so a missing account
surfaces a clear error instead of a downstream TypeError (PR #46 review, #15).
…hapes

Recovery previously took an all-optional bag, so recoverDepositAddress({})
type-checked. Model it as SdaRecoverById | SdaRecoverByAddress instead — a caller
must identify the deposit by SDA id or by deposit address, and the empty call is
now a type error. Drops the unused sourceTxHash field; a provider needing other
inputs extends the relevant member (PR #46 review, #13; Davi0k approved).
The shared interface should describe behavior in provider-neutral terms; the
descriptions survive a provider swap untouched. Dropped the "(Orchestra, Relay)"
and "(e.g., Rhino)" asides from SdaRecoveryMode and SdaQuoteOptions (AD11).
…t class

tsc's JS->.d.ts declaration emit flattens JSDoc @interface and @abstract to
plain `class` (verified: regenerating swap-protocol.d.ts flips its hand-maintained
`interface` / `abstract class` back to `class`). Hand-edit the SDA declarations to
match the sibling protocols and the .d.ts maintenance convention (TD7/TD8).
… estimate

createDepositAddress no longer accepts a pre-fetched quote — a provider whose
address binds to a quote fetches one internally with a cache (the #7 conclusion:
never require callers to quote up front). quoteDeposit / SdaQuote / SdaQuoteOptions
stay as an optional standalone estimate (real providers like Rhino and Relay
expose quote endpoints); only the quote-first coupling and the now-stale
"required up front" docs are removed. .d.ts hand-edited, no build:types.
…erwise

Clarify that a provider whose deposit limits are enforced in another denomination
(e.g. USD) omits `limits` rather than converting into the input token's base unit,
so the field never carries a mismatched unit. .d.ts hand-edited, no build:types.
Comment thread src/errors.js Outdated
Comment thread src/protocols/sda-protocol.js
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js Outdated
Comment thread src/protocols/sda-protocol.js
- errors: add ValueError (correct type, invalid value) and NoSuchElementError
  (lookup miss); remove the now-unused AccountRequiredError; trim the
  UnsupportedOperationError description.
- ISdaProtocol methods now all throw NotImplementedError; the abstract
  SdaProtocol keeps throwing UnsupportedOperationError for optional operations
  (the base default a non-supporting provider inherits).
- @throws: route-discovery validation -> ValueError; create/derive missing
  destination -> ValueError; not-found lookups -> NoSuchElementError; standardize
  the UnsupportedOperationError condition text.
.d.ts hand-edited, no build:types.
…face

- rename types: SdaLimits->SdaDepositAddressLimits, SdaQuoteOptions->SdaDepositOptions,
  SdaQuote->SdaDepositQuote, SdaCreateOptions->SdaCreateDepositAddressOptions,
  destinationAsset->outputAsset; method getTransferStatus->getTransfer.
- delete the docs-only enum typedefs (SdaRouteDiscoveryMode/SdaActivationModel/
  SdaCustodyModel/SdaRecoveryMode); inline as plain text.
- KISS docs: drop the "Optional:" phrasing, the descriptive-traits and abstract-class
  boilerplate, and the SdaRecoveryOptions "type error" tail; provider -> protocol.
- abstract SdaProtocol: optional methods are concrete defaults (drop @abstract).
- .d.ts regenerated to match the doc rewrite; interface/abstract keywords re-applied.
inputToken, outputAsset and fees are not populated by any reference protocol, so
drop them from SdaTransfer. id + status stay required; the rest are optional since
protocols/endpoints report different subsets (review comment). .d.ts hand-edited.
Long @Property / @param descriptions ran up to 255 chars; wrap them onto
continuation lines so documentation caps at 120 columns, as requested in review.
{@link} tags are kept intact on a single line. .d.ts comments wrapped to match.
AlonzoRicardo added a commit to quocle108/wdk-wallet that referenced this pull request Jul 10, 2026
…racts

Adds SignerError, ValueError and NoSuchElementError (verbatim from the taxonomy agreed on tetherto#46) to the core errors module and exports them, and documents them via @throws on the multisig interface contracts (propose/approve/reject/execute/quoteExecuteProposal + owner management). Interface bodies still throw NotImplementedError; @throws documents the contract implementations must honor. UnsupportedOperationError is deferred with the optional-method (abstract-base) work.

Mirrors tetherto#46's agreed definitions so the shared errors module reconciles cleanly when both land.
The committed declarations had their doc comments re-wrapped independently at 120
columns, which diverged from what tsc actually emits: it inherits the source's line
breaks for verbatim-copied JSDoc, and uses a flat `* ` continuation for the
synthesized @Property comments. Normalize to tsc's layout so the only remaining
delta from a fresh regen is the two hand-maintained keywords (interface
ISdaProtocol / abstract class SdaProtocol), which makes future type-check diffs
meaningful instead of noisy.

Comment-only: no signature, type or member changes.
Comment thread src/errors.js Outdated
Comment thread types/src/protocols/sda-protocol.d.ts Outdated
- errors: trim the ValueError description.
- types/: strip the leading JSDoc @typedef blocks from the SDA declarations, so the
  file opens at its first declaration like every sibling .d.ts.
- SdaDepositAddress: drop the `quote` field. Only a quote-bound protocol populates
  it, so it belongs on that protocol's own type, not the shared one.
- SdaTransfer: reduce to `id` + `status`. Verified against the four providers' APIs:
  Candide's forwarding_getStatus returns no amounts, fees, assets or updatedAt, so
  those fields cannot be shared. Protocols with richer records extend the type;
  fields can be added back later without a breaking change.
…essOptions

Apply the shared-field rule to the create options: a field belongs on the shared
type only when every protocol has it. Verified against the providers' APIs:

- inputToken: Relay (originCurrency) and Orchestra (sourceAsset, which binds the
  address to a single deposit token) both REQUIRE it; Rhino and Candide have no
  such parameter. Required for two, absent for two — so it belongs on their own
  options types, where it can be typed as required rather than shared as optional.
- reusable: only Relay has a single-vs-multi-use toggle (strict). Rhino's
  reusePolicy is a different concept (reuse-existing vs create-new address), which
  the boolean cannot express; Orchestra and Candide addresses are always reusable.
- refundAddress: supported by Relay (refundTo) and Rhino, absent from Orchestra's
  schema, and structurally N/A for self-custodial Candide.

The descriptor keeps supportedInputTokens and reusable (required) — those are how a
consumer learns what an address accepts and whether it is single-use. Fields can be
added back later without a breaking change. .d.ts hand-edited, no build:types.

@Davi0kProgramsThings Davi0kProgramsThings left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM.

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.

5 participants