TransferSender
Owner context: shifts | Entity: Transfer | Field: sender | Value object
Every Transfer is dispatched by exactly one sender. The sender is the dispatch channel used to move funds out — either a Switchain-custodial wallet or an external CEX account. Orthogonal to TransferStatus: status describes the lifecycle, sender describes who executes it.
Values
Source of truth: senders const at packages/shifts/domain/valueObjects/TransferSender.ts:1.
| Sender | Kind | Selectable today | Notes |
|---|---|---|---|
sws | Custodial | Yes | Switchain-custodial wallet. isSws() true. |
binance | CEX | Yes | isCex() true. The only CEX returned by Cex.active(). |
htx | CEX | No (accepted on existing transfers, not chosen by the retriever) | isCex() true. Present in TransferSender.activeSenders() but excluded from Cex.active(); the retriever never picks it. |
kucoin | CEX | No | isCex() true. Historical only. |
own | Historical | No | Pre-custodial transfers. |
legacy | Fallback | No | Catch-all for unknown senders via fromStringOrLegacy(). |
TransferSender.activeSenders() returns ['sws', 'binance', 'htx'] (TransferSender.ts:21-23). "Selectable today" in the table above reflects which senders the retriever will actually choose for new transfers — not every active sender.
Selection strategy
When a shift needs a new transfer, ShiftTransferSenderRetriever.run() picks the sender before the transfer is created:
effectiveBalance = balance − sum of open transfers already assigned to that sender (retriever.ts:45-57, 68-80). This reservation prevents double-spending pending balance across in-flight transfers.
Key details:
- Custodial is preferred. The CEX branch only runs when the custodial check fails.
- Only one CEX is tried.
Cex.binanceis hardcoded at retriever.ts:59 — there is no fallback from Binance to HTX/Kucoin. - Strict inequality. The check is
effectiveBalance.isGreaterThan(amount), not>=. A transfer exactly equal to available balance is rejected. undefined⇒ no transfer. The caller interprets a missing sender as "cannot dispatch right now" — no transfer is created.
Dispatch
Once the transfer exists with a sender assigned, ShiftTransferSender.run() branches on the sender kind:
| Branch | Condition | Adapter | Path |
|---|---|---|---|
| Custodial | transfer.sender.isSws() | WalletWithdrawer from @sws/wallets | swsTransfer() at L44-L71 |
| CEX | transfer.sender.isCex() | CexMainAccountWithdrawer from @sws/cex-wallets | cexTransfer() at L73-L115 |
run() is a no-op unless the transfer is in open. In the CEX branch, Cex.fromString(sender.toString()) converts the shifts-domain TransferSender into the common-domain Cex value object that the adapter accepts.
Relation to TransferStatus
Sender is orthogonal to status, with one exception:
suspendedis CEX-only. The only code path that writesTransferStatus.suspendediscexTransfer()'s catch block onCexWithdrawalsSuspendedError(ShiftTransferSender.ts:107-112). Answstransfer never enterssuspended— custodial withdrawal failures return early withShiftTransferInsufficientBalanceDomainEventor throw, they do not suspend.
All other TransferStatus transitions (transfer-status.md) apply regardless of sender.
Ownership
| Concept | Package | File |
|---|---|---|
TransferSender value object | packages/shifts/domain | valueObjects/TransferSender.ts |
| Sender selection | packages/shifts/application | services/transfers/ShiftTransferSenderRetriever.ts |
| Sender dispatch | packages/shifts/application | services/transfers/ShiftTransferSender.ts |
| Supported CEX list (cross-context) | packages/common/domain | valueObjects/Cex.ts |
| Custodial withdrawal adapter | packages/wallets | WalletWithdrawer |
| CEX withdrawal adapter | packages/cex-wallets | CexMainAccountWithdrawer, CexMainAccountWithdrawChecker |
The shifts domain owns which senders exist. The common domain owns which CEXes are recognised platform-wide. The cex-wallets and wallets packages own how to actually withdraw.
Legacy name translation
Historical records stored CEX names as binance_dac and huobi. TransferSender.translateFromLegacy() and Cex.fromSwitchain() map those to the current binance / htx identifiers on read (TransferSender.ts:72-90, Cex.ts:4-10). Use translateToLegacy() / toSwitchain() when writing to external systems that still expect the old names.
Related
- State machine of the same entity:
TransferStatus - CEX adapters (per-exchange detail): integrations/cexes
- Owner context:
shiftsreadme
