shift-refund
Spans: shifts → wallets → cex-wallets (last only when the refund Transfer's sender is a CEX) Trigger: ShiftLooseTransferCreator on slippage (inline, supervisor-driven) · admin HTTP via ShiftRefunderController · admin force via ShiftStatusUpdateController Terminal states: refunded (happy path) · failed (refund Transfer failed — recoverable by admin)
A branch of shift-lifecycle, not an independent saga. The refund path reuses the same Transfer pipeline (TransferSenderQueue, TransferUpdaterQueue) but with motive=refund and targetAddress = shift.refundAddress — the wallet the deposit came from. The user receives their original deposit amount back (transactionInfo.amount or shift.amountFrom) in the input coin; CEX trades are never reversed, and a refunded shift never creates a Due — only confirmed cascades to partners (see shift-lifecycle step 9).
Entry states
Sources that reach refunding, per ShiftStatus.refundableStatuses:
| Source | Trigger | Notes |
|---|---|---|
exchanging | ShiftLooseTransferCreator slippage check | Only loose shifts auto-refund; precise shifts never self-refund on slippage |
exchanging / expired / failed / suspended | Admin via ShiftRefunderController | Explicit refund; ShiftRefunder.isRefundable() gates the attempt |
banned | Admin via ShiftStatusUpdateController | Mandatory: banned → refunding is the only outgoing edge in the state matrix |
ShiftRefunder additionally requires depositTxId set and no existing transferId (ShiftRefunder.ts:88-112).
Pull vs. event-driven
| Boundary | Mechanism |
|---|---|
Source → refunding (slippage path) | Polling — ShiftSupervisor runs ShiftLooseTransferCreator, which invokes ShiftRefunder inline when shouldRefund returns true |
Source → refunding (admin path) | HTTP-triggered — outside the supervisor loop |
Refund Transfer creation → dispatch | Event-driven — TransferCreatedDomainEvent → TransferSenderQueueOnCreated (same subscriber as happy-path Transfers) |
Refund Transfer dispatch → polling | Event-driven — TransferSentDomainEvent → TransferUpdaterQueueOnSent |
refunding → refunded | Polling — ShiftSupervisor calls ShiftRefundConfirmer each cycle, which reads Transfer.status |
Rule of thumb: entering refunding is inline/HTTP, leaving to refunded is polling, and the Transfer in between is event-driven — the same pipeline as steps 6–7 of shift-lifecycle.
Sequence
Events
| # | Event | Emitted by | Consumed by | Effect |
|---|---|---|---|---|
| 1 | ShiftRefundingDomainEvent | ShiftRefunder | (no internal subscriber; external notifications only) | Shift <source> → refunding; transferId set on the shift |
| 2 | TransferCreatedDomainEvent (motive=refund) | ShiftRefunder | TransferSenderQueueOnCreated (wallets-worker) | Creates Transfer (open) with motive=refund, motiveId=shift._id, recipient=external, targetAddress=shift.refundAddress |
| 3 | TransferSentDomainEvent | ShiftTransferSender | TransferUpdaterQueueOnSent | Transfer open → sending — same path as shift-lifecycle step 6 |
| 4 | TransferConfirmedDomainEvent | ShiftTransferUpdater | (read by ShiftRefundConfirmer via polling) | Transfer → confirmed — same path as shift-lifecycle step 7 |
| 5 | ShiftRefundedDomainEvent | ShiftRefundConfirmer | (no internal subscriber) | Shift refunding → refunded; setRefunded() sets refundedAt; carries pairId, status, depositAddress |
ShiftRefundConfirmer also calls shift.updateRefundTxId(transfer.txHash) on the way through (ShiftRefundConfirmer.ts:44-46), so the refund tx hash is persisted even on intermediate polls before the Transfer reaches confirmed.
Steps 3 and 4 are the same pipeline as the happy path — the only difference is the Transfer's motive. There is no refund-only sender or updater.
Failure modes
| Scenario | Terminal state | Where it's decided | Recovery / runbook |
|---|---|---|---|
| Refund broadcast fails / on-chain tx reverts | Transfer failed → Shift refunding → failed | ShiftTransferUpdater | Replace via ShiftTransferReplacer — refund motive is replaceable (TransferMotive.ts:48-50); or admin force back to refunding |
| CEX refuses the refund withdrawal (coin disabled, account banned) | Transfer suspended → Shift stuck in refunding | ShiftTransferSender.cexTransfer() | Wait for CEX to re-enable; otherwise ShiftTransferReplacer to switch sender |
| No eligible sender for the refund (insufficient liquidity) | Shift stays in source state; no Transfer is created | ShiftRefunder.run() returns early when senderRetriever returns null (ShiftRefunder.ts:45-50) | Operator tops up an eligible sender; supervisor retries next cycle |
| Deposit transaction never confirms | ShiftRefunder throws; shift stays in source state | isDepositTxConfirmed() guard (ShiftRefunder.ts:117-133) | Manual — confirm the deposit or move the shift to failed |
| Refund attempted from non-refundable status | ShiftRefunder throws | isRefundable() guard (ShiftRefunder.ts:88-96) | Admin pushes the shift to a refundable state first |
Terminal paths
refunded is a true sink. failed is recoverable — admin can push failed → refunding via ShiftStatusUpdateController and retry, typically together with ShiftTransferReplacer to use a different sender.
Code Pointers
- Refund creation: packages/shifts/application/services/ShiftRefunder.ts
- Refund confirmation (supervisor-polled): packages/shifts/application/services/ShiftRefundConfirmer.ts
- Slippage trigger: packages/shifts/application/services/transfers/ShiftLooseTransferCreator.ts#L74-L78; slippage math at L139-L149
- Supervisor hook: ShiftSupervisor.ts#L46-L48
- Admin controllers:
ShiftRefunderController.ts; ShiftStatusUpdateController.ts (force=trueoverrides) - Domain events: ShiftRefundingDomainEvent.ts, ShiftRefundedDomainEvent.ts
- Refundable statuses: ShiftStatus.ts#L105-L111
- Transfer motive: TransferMotive.ts —
refundat L7,isReplaceable()at L48-L50 - Reused Transfer pipeline:
ShiftTransferSender.ts,ShiftTransferUpdater.ts,ShiftTransferReplacer.ts— see shift-lifecycle code pointers
Related
- Parent flow:
shift-lifecycle(this is its refund branch) - State machines:
ShiftStatus·TransferStatus·TransactionStatus - Contexts:
shifts·wallets·cex-wallets - Sender selection:
TransferSender
