TransferStatus
Owner context: shifts | Entity: Transfer | Field: status | Values: 8
The lifecycle of a Transfer (the outbound payment leg of a Shift, or its refund). Each Shift owns one or more Transfers through motiveId; the shift advances only when its transfer reaches confirmed or replaced. The happy path is open → sending → confirming → confirmed, with branches for CEX suspension, manual cancellation, and replacement after failure.
The sender (TransferSender) is orthogonal to status — every transfer is dispatched by either a custodial wallet (sws) or a CEX (binance, htx, kucoin). See TransferSender for the selection strategy and dispatch paths.
States
| State | Terminal | Description | Entry condition |
|---|---|---|---|
open | No | Transfer created, not yet sent to the chain or CEX | Shift{Precise,Loose}TransferCreator on shift entering exchanging or refunding |
sending | No | Withdrawal initiated at the CEX; awaiting tx hash | ShiftTransferSender |
confirming | No | Tx hash known, awaiting chain confirmations | ShiftTransferUpdater during polling |
suspended | No | Paused (typically CEX withdrawal disabled for the coin) | ShiftTransferSender when CEX refuses |
confirmed | Yes (sink) | Transfer completed on-chain | ShiftTransferUpdater on final confirmation |
replaced | Yes (sink) | This transfer was superseded by another one (new sender, retry) | ShiftTransferReplacer |
failed | Yes (recoverable) | Send or confirmation failed | ShiftTransferUpdater / ShiftTransferSender on error |
canceled | Yes (recoverable) | Manually canceled | Transfer.cancel() |
Sink = getValidTransitions() returns []. Recoverable = isFinal() returns true but the matrix still allows outgoing transitions (replacement, recovery, re-send after suspension).
Transitions
Transition Table
Source of truth: TransferStatus.getValidTransitions() at packages/shifts/domain/valueObjects/TransferStatus.ts:103-133.
| From | To | Trigger (service / command) | Invariant | Side effects |
|---|---|---|---|---|
open | sending | ShiftTransferSender on TransferCreatedDomainEvent | CEX withdrawal accepted | Emits TransferSentDomainEvent; stores withdrawId |
open | suspended | ShiftTransferSender when CEX refuses withdrawal | Withdrawals disabled for this coin | Emits TransferSuspendedDomainEvent |
open | canceled | Transfer.cancel() (admin) | — | — |
open | replaced | ShiftTransferReplacer (rare, pre-send replacement) | Replacement transfer exists | Emits FailedTransferReplacedDomainEvent; sets replacedBy |
sending | confirming | ShiftTransferUpdater polling (tx hash appears) | txHash set | Emits TransferTxHashUpdatedDomainEvent |
sending | confirmed | ShiftTransferUpdater when withdraw reports final | Tx final on chain | Emits TransferConfirmedDomainEvent |
sending | failed | ShiftTransferUpdater / ShiftTransferSender on error | — | Emits TransferFailedDomainEvent |
sending | suspended | Mid-send CEX suspension | — | Emits TransferSuspendedDomainEvent |
confirming | confirmed | ShiftTransferUpdater on confirmation count met | — | Emits TransferConfirmedDomainEvent |
confirming | failed | ShiftTransferUpdater on chain failure | — | Emits TransferFailedDomainEvent |
suspended | sending | ShiftTransferSender retry after CEX re-enables | CEX accepting withdrawals again | Emits TransferSentDomainEvent |
suspended | canceled | Transfer.cancel() (admin) | — | — |
failed | replaced | ShiftTransferReplacer | New transfer created with different sender | Emits FailedTransferReplacedDomainEvent; sets replacedBy |
failed | confirmed | Manual recovery (admin) when the tx actually confirmed on-chain | Transfer truly landed | Emits TransferConfirmedDomainEvent |
canceled | replaced | ShiftTransferReplacer | Replacement transfer exists | Emits FailedTransferReplacedDomainEvent; sets replacedBy |
Any transition not listed here is rejected by Transfer.updateStatus() (Transfer.ts:127-134). Note: canTransition() returns true when source and target are the same state, so re-applying the current status is a no-op (not an error).
Invariants
- Initial state is always
open—Transfer.create()hardcodes it (Transfer.ts:76). - Only
failedandcanceledcan be replaced —canBeReplaced()at TransferStatus.ts:99-101.ShiftTransferReplacerenforces this before creating a new transfer and marking the old onereplaced. - True sinks are
confirmedandreplaced—getValidTransitions()returns[]for both (TransferStatus.ts:127-129). These are the only states the matrix locks in forever. isFinal()includes non-sinks — returns true for[confirmed, replaced, failed, canceled](TransferStatus.ts:87-89), butfailedandcanceledstill have outgoing transitions (replacement, manual recovery). TreatisFinal()as "transfer reached a conclusion", not "no more transitions".isCompleted()is the signal for the shift to advance — returns true for[confirmed, replaced]only (TransferStatus.ts:83-85).ShiftConfirmeruses this to move the owningShiftfromconfirmingtoconfirmed.- Same-state transitions are allowed (no-op) —
canTransition()returnstruewhen the target equals the current value (TransferStatus.ts:92-94). This means callingupdateStatus(currentStatus)never throws. failed → confirmedexists for recovery — a transfer initially reported as failed may actually have landed on-chain. Admin tooling can move it toconfirmedwithout replacing it. Use sparingly; prefer replacement.suspendedis bidirectional withsending— unlike the shift'ssuspended, a transfer can bounce back tosendingwhen the CEX re-enables withdrawals. This is the main retry path during operational CEX outages.suspendedis CEX-only — the sole writer ofsuspendedis theCexWithdrawalsSuspendedErrorcatch inShiftTransferSender.cexTransfer()(ShiftTransferSender.ts:107-112). A transfer withsender.isSws()never enterssuspended; custodial failures return early or throw. SeeTransferSender.
Cross-context effect: Transfer → Shift
A transfer transition drives the shift's state via event subscribers:
| Transfer transition | Triggers on the Shift |
|---|---|
sending → confirmed or confirming → confirmed | ShiftConfirmer moves the shift confirming → confirmed (and, for refund transfers, refunding → refunded via ShiftRefundConfirmer) |
open → suspended | SuspendShiftOnTransferSuspended subscriber moves the shift to suspended (apps/shifts-worker/application/eventSubscribers/SuspendShiftOnTransferSuspended.ts) |
failed → replaced | ShiftTransferReplacer creates a new transfer in open; shift remains in confirming (or refunding) throughout |
Full cross-context saga: shift-lifecycle (to be documented).
Code Pointers
- Enum and matrix: packages/shifts/domain/valueObjects/TransferStatus.ts
- Transition enforcement: packages/shifts/domain/entities/Transfer.ts (
updateStatus,cancel,createWithNewSender) - Transfer creation: packages/shifts/application/services/transfers/ShiftPreciseTransferCreator.ts,
ShiftLooseTransferCreator.ts - Send and suspension: packages/shifts/application/services/transfers/ShiftTransferSender.ts
- Status polling: packages/shifts/application/services/transfers/ShiftTransferUpdater.ts
- Replacement: packages/shifts/application/services/transfers/ShiftTransferReplacer.ts
- Drive owning shift to confirmed: packages/shifts/application/services/ShiftConfirmer.ts
- Shift suspension on transfer suspension: apps/shifts-worker/application/eventSubscribers/SuspendShiftOnTransferSuspended.ts
Related
- Owner context:
shifts - Sibling value object on the same entity (dispatch channel):
TransferSender - Sibling state machine on the shift leg:
ShiftStatus - Upstream chain state (every transfer eventually needs a chain tx to confirm):
TransactionStatus(to be documented) - Cross-context flow that drives most transitions:
shift-lifecycle(to be documented)
