Skip to content

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

StateTerminalDescriptionEntry condition
openNoTransfer created, not yet sent to the chain or CEXShift{Precise,Loose}TransferCreator on shift entering exchanging or refunding
sendingNoWithdrawal initiated at the CEX; awaiting tx hashShiftTransferSender
confirmingNoTx hash known, awaiting chain confirmationsShiftTransferUpdater during polling
suspendedNoPaused (typically CEX withdrawal disabled for the coin)ShiftTransferSender when CEX refuses
confirmedYes (sink)Transfer completed on-chainShiftTransferUpdater on final confirmation
replacedYes (sink)This transfer was superseded by another one (new sender, retry)ShiftTransferReplacer
failedYes (recoverable)Send or confirmation failedShiftTransferUpdater / ShiftTransferSender on error
canceledYes (recoverable)Manually canceledTransfer.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.

FromToTrigger (service / command)InvariantSide effects
opensendingShiftTransferSender on TransferCreatedDomainEventCEX withdrawal acceptedEmits TransferSentDomainEvent; stores withdrawId
opensuspendedShiftTransferSender when CEX refuses withdrawalWithdrawals disabled for this coinEmits TransferSuspendedDomainEvent
opencanceledTransfer.cancel() (admin)
openreplacedShiftTransferReplacer (rare, pre-send replacement)Replacement transfer existsEmits FailedTransferReplacedDomainEvent; sets replacedBy
sendingconfirmingShiftTransferUpdater polling (tx hash appears)txHash setEmits TransferTxHashUpdatedDomainEvent
sendingconfirmedShiftTransferUpdater when withdraw reports finalTx final on chainEmits TransferConfirmedDomainEvent
sendingfailedShiftTransferUpdater / ShiftTransferSender on errorEmits TransferFailedDomainEvent
sendingsuspendedMid-send CEX suspensionEmits TransferSuspendedDomainEvent
confirmingconfirmedShiftTransferUpdater on confirmation count metEmits TransferConfirmedDomainEvent
confirmingfailedShiftTransferUpdater on chain failureEmits TransferFailedDomainEvent
suspendedsendingShiftTransferSender retry after CEX re-enablesCEX accepting withdrawals againEmits TransferSentDomainEvent
suspendedcanceledTransfer.cancel() (admin)
failedreplacedShiftTransferReplacerNew transfer created with different senderEmits FailedTransferReplacedDomainEvent; sets replacedBy
failedconfirmedManual recovery (admin) when the tx actually confirmed on-chainTransfer truly landedEmits TransferConfirmedDomainEvent
canceledreplacedShiftTransferReplacerReplacement transfer existsEmits 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

  1. Initial state is always openTransfer.create() hardcodes it (Transfer.ts:76).
  2. Only failed and canceled can be replacedcanBeReplaced() at TransferStatus.ts:99-101. ShiftTransferReplacer enforces this before creating a new transfer and marking the old one replaced.
  3. True sinks are confirmed and replacedgetValidTransitions() returns [] for both (TransferStatus.ts:127-129). These are the only states the matrix locks in forever.
  4. isFinal() includes non-sinks — returns true for [confirmed, replaced, failed, canceled] (TransferStatus.ts:87-89), but failed and canceled still have outgoing transitions (replacement, manual recovery). Treat isFinal() as "transfer reached a conclusion", not "no more transitions".
  5. isCompleted() is the signal for the shift to advance — returns true for [confirmed, replaced] only (TransferStatus.ts:83-85). ShiftConfirmer uses this to move the owning Shift from confirming to confirmed.
  6. Same-state transitions are allowed (no-op)canTransition() returns true when the target equals the current value (TransferStatus.ts:92-94). This means calling updateStatus(currentStatus) never throws.
  7. failed → confirmed exists for recovery — a transfer initially reported as failed may actually have landed on-chain. Admin tooling can move it to confirmed without replacing it. Use sparingly; prefer replacement.
  8. suspended is bidirectional with sending — unlike the shift's suspended, a transfer can bounce back to sending when the CEX re-enables withdrawals. This is the main retry path during operational CEX outages.
  9. suspended is CEX-only — the sole writer of suspended is the CexWithdrawalsSuspendedError catch in ShiftTransferSender.cexTransfer() (ShiftTransferSender.ts:107-112). A transfer with sender.isSws() never enters suspended; custodial failures return early or throw. See TransferSender.

Cross-context effect: Transfer → Shift

A transfer transition drives the shift's state via event subscribers:

Transfer transitionTriggers on the Shift
sending → confirmed or confirming → confirmedShiftConfirmer moves the shift confirming → confirmed (and, for refund transfers, refunding → refunded via ShiftRefundConfirmer)
open → suspendedSuspendShiftOnTransferSuspended subscriber moves the shift to suspended (apps/shifts-worker/application/eventSubscribers/SuspendShiftOnTransferSuspended.ts)
failed → replacedShiftTransferReplacer creates a new transfer in open; shift remains in confirming (or refunding) throughout

Full cross-context saga: shift-lifecycle (to be documented).

Code Pointers

  • 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)