shift-lifecycle
Spans: shifts ↔ wallets ↔ cex-wallets → partners Trigger: POST /v3/order on apps/sw-api → ShiftCreator.run() Terminal states: confirmed (happy path) · refunded (slippage or explicit refund) · failed · expired · suspended
The end-to-end saga of a Switchain shift: from order creation through deposit detection, CEX trading, transfer dispatch, on-chain settlement, and partner due accounting. This flow drives the three core state machines in lockstep:
ShiftStatus— the orchestrating status (12 states).TransferStatus— the outbound payment leg (8 states).TransactionStatus— the custodial wallet tx, when the sender issws(6 states).
Pull vs. event-driven
The flow is a hybrid: domain events enqueue BullMQ jobs for downstream work, but most cross-state-machine signaling happens via polling the next layer's status. Knowing which is which is load-bearing — when something is stuck, look at the right boundary.
| Boundary | Mechanism |
|---|---|
Shift status transitions (waiting → received → exchanging → confirming → confirmed/refunded) | Polling — ShiftSupervisor runs on a BullMQ recurring job (~60 s) and calls each phase's service in turn |
ShiftCreatedDomainEvent → start supervising | Event-driven — ShiftSupervisorOnCreated enqueues the supervisor job |
ShiftReceivedDomainEvent → CEX spot trades | Event-driven — CreateTradesOnShiftDepositReceived enqueues CexShiftTradesCreatorQueue |
TransferCreatedDomainEvent → send the transfer | Event-driven — TransferSenderQueueOnCreated enqueues TransferSenderQueue |
TransferSentDomainEvent → poll the transfer | Event-driven — TransferUpdaterQueueOnSent enqueues TransferUpdaterQueue |
Transfer status (open → sending → confirmed/failed) | Pull — ShiftTransferUpdater reads Transaction.status (for sws sender) or calls the CEX withdraw API (for CEX senders) |
Shift.confirming → confirmed | Pull — ShiftConfirmer reads Transfer.status inside the supervisor loop (no TransferConfirmedDomainEvent subscriber triggers it) |
ShiftConfirmedDomainEvent → create partner due | Event-driven — DueCreatorOnShiftConfirmed enqueues DueCreatorQueue |
Rule of thumb: state transitions within a context are polling-driven; cross-context hand-offs are event-driven. The only exception is Shift.confirming → confirmed, which reads Transfer.status by pulling — an internal boundary that behaves like a cross-context one because Transfer's terminal state is what decides it.
Sequence (happy path)
Events
| # | Event | Emitted by | Consumed by | Effect |
|---|---|---|---|---|
| 1 | ShiftCreatedDomainEvent | ShiftCreator on POST /v3/order | ShiftSupervisorOnCreated (shifts-worker); ShiftKytAnalyzerOnCreated | Enqueues supervisor recurring job; KYT compliance check |
| 2 | ShiftReceivedDomainEvent | ShiftDepositDetector during supervisor poll | CreateTradesOnShiftDepositReceived (wallets-worker) | Shift waiting → received; enqueues CEX trades loop |
| 3 | ShiftExchangingDomainEvent | ShiftDepositConfirmer during supervisor poll | — (supervisor continues internally) | Shift received → exchanging; deposit confirmed on-chain |
| 4 | (no event) | CexAccountSpotTradeCreator via CexShiftTradesCreatorQueue | — (poll-based completion check) | Converts deposit coin to output coin via CEX spot trades |
| 5 | TransferCreatedDomainEvent + ShiftConfirmingDomainEvent | ShiftLooseTransferCreator / ShiftPreciseTransferCreator | TransferSenderQueueOnCreated (wallets-worker) | Creates Transfer (status open); shift exchanging → confirming; enqueues transfer sender |
| 6 | TransferSentDomainEvent | ShiftTransferSender via TransferSenderQueue | TransferUpdaterQueueOnSent (wallets-worker) | Transfer open → sending; records withdrawId; for sws sender creates a Transaction (pending → submitted); enqueues transfer updater |
| 7 | TransferConfirmedDomainEvent | ShiftTransferUpdater via TransferUpdaterQueue (pulls Transaction.status or CEX withdraw API) | No direct subscriber — read by ShiftConfirmer via polling | Transfer sending/confirming → confirmed; stores txHash |
| 8 | ShiftConfirmedDomainEvent | ShiftConfirmer during supervisor poll (after reading Transfer.status) | DueCreatorOnShiftConfirmed (shifts-worker) | Shift confirming → confirmed; enqueues DueCreatorQueue with 5 s delay |
| 9 | DueCreatedDomainEvent | DueCreator via DueCreatorQueue | ShiftProfitsCalculatorOnDueCreated (and partner payout chain) | Creates Due in partners context; kicks off profit/payout cycle |
Failure modes
| Scenario | Terminal state | Where it's decided | Recovery / runbook |
|---|---|---|---|
| No deposit arrives before TTL | expired | ShiftDepositDetector timeout | Admin push back to received/exchanging if deposit arrives late (ShiftStatus recovery edges) |
| Deposit tx invalidated / wrong amount | failed | ShiftDepositDetector / ShiftDepositConfirmer | Admin recovery to expired, then refund via ShiftRefunder |
| KYT flags deposit address | suspended | KYT integration during deposit check | ShiftUnsuspender if address clears, else refund |
| Slippage breach at loose transfer creation | refunded (via refunding) | ShiftLooseTransferCreator detects price deviation | ShiftRefunder creates a new refund Transfer; flow re-enters steps 6–7 with motive=refund |
| CEX refuses withdrawal (coin disabled) | sending → suspended (Transfer) + Shift → suspended via SuspendShiftOnTransferSuspended | ShiftTransferSender.cexTransfer() catches CexWithdrawalsSuspendedError (ShiftTransferSender.ts:107-112) | Wait for CEX to re-enable (→ retry suspended → sending) or refund |
| CEX account banned mid-flow | Transfer stuck; Shift stuck confirming | ShiftTransferSender | Operator intervention; ShiftTransferReplacer to switch sender or refund |
| Transfer broadcast fails | open → failed (Transfer) | ShiftTransferSender | ShiftTransferReplacer creates a new Transfer with a different sender; old one marked replaced |
| On-chain tx reverts or drops from mempool | Transfer → failed; underlying Transaction → failed (via notOnChain + timeout in TransactionUpdater) | TransactionUpdater → ShiftTransferUpdater reads it | Replace via CPFP/RBF (ShiftTransferReplacer) or manual failed → confirmed if the tx actually landed |
| Banned shift (compliance) | banned → refunding → refunded | Admin force=true transition; ShiftRefunder mandatory refund | — |
Terminal paths
Only confirmed reaches the partners leg (step 9). refunded replays steps 6–7 with a refund transfer but never emits ShiftConfirmedDomainEvent.
Code Pointers
- Trigger (HTTP entry): apps/sw-api/infrastructure/routes/v3Routes.ts
- Entry service: packages/shifts/application/services/ShiftCreator.ts
- Supervisor (poll loop): packages/shifts/application/services/ShiftSupervisor.ts; queue at apps/shifts-worker/infrastructure/queues/ShiftSupervisorQueue.ts
- Phase services (invoked by the supervisor):
ShiftDepositDetector,ShiftDepositConfirmer,ShiftLooseTransferCreator,ShiftPreciseTransferCreator,ShiftConfirmer - Transfer dispatch: packages/shifts/application/services/transfers/ShiftTransferSender.ts (see
TransferSenderfor sender selection) - Transfer polling: packages/shifts/application/services/transfers/ShiftTransferUpdater.ts; queue at apps/wallets-worker/infrastructure/queues/transfers/TransferUpdaterQueue.ts
- Transfer replacement (failure recovery): packages/shifts/application/services/transfers/ShiftTransferReplacer.ts
- Refund: packages/shifts/application/services/ShiftRefunder.ts, packages/shifts/application/services/ShiftRefundConfirmer.ts
- CEX trades: packages/cex-wallets/application/services/trades/CexAccountSpotTradeCreator.ts; queue at apps/wallets-worker/infrastructure/queues/cex-wallets/CexShiftTradesCreatorQueue.ts
- Partner due: apps/shifts-worker/application/eventSubscribers/DueCreatorOnShiftConfirmed.ts
- Cross-context subscriber (Transfer → Shift suspension): apps/shifts-worker/application/eventSubscribers/SuspendShiftOnTransferSuspended.ts
Related
- State machines:
ShiftStatus·TransferStatus·TransactionStatus - Sender selection:
TransferSender - Contexts:
shifts·wallets·cex-wallets·partners - Branch flows:
shift-refund(refund path) ·payment-run(downstream, partner payouts) - Future flows:
transfer-execution(to be documented)
