Skip to content

shift-refund

Spans: shiftswalletscex-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:

SourceTriggerNotes
exchangingShiftLooseTransferCreator slippage checkOnly loose shifts auto-refund; precise shifts never self-refund on slippage
exchanging / expired / failed / suspendedAdmin via ShiftRefunderControllerExplicit refund; ShiftRefunder.isRefundable() gates the attempt
bannedAdmin via ShiftStatusUpdateControllerMandatory: 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

BoundaryMechanism
Source → refunding (slippage path)PollingShiftSupervisor runs ShiftLooseTransferCreator, which invokes ShiftRefunder inline when shouldRefund returns true
Source → refunding (admin path)HTTP-triggered — outside the supervisor loop
Refund Transfer creation → dispatchEvent-drivenTransferCreatedDomainEventTransferSenderQueueOnCreated (same subscriber as happy-path Transfers)
Refund Transfer dispatch → pollingEvent-drivenTransferSentDomainEventTransferUpdaterQueueOnSent
refunding → refundedPollingShiftSupervisor 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

#EventEmitted byConsumed byEffect
1ShiftRefundingDomainEventShiftRefunder(no internal subscriber; external notifications only)Shift <source> → refunding; transferId set on the shift
2TransferCreatedDomainEvent (motive=refund)ShiftRefunderTransferSenderQueueOnCreated (wallets-worker)Creates Transfer (open) with motive=refund, motiveId=shift._id, recipient=external, targetAddress=shift.refundAddress
3TransferSentDomainEventShiftTransferSenderTransferUpdaterQueueOnSentTransfer open → sending — same path as shift-lifecycle step 6
4TransferConfirmedDomainEventShiftTransferUpdater(read by ShiftRefundConfirmer via polling)Transfer → confirmed — same path as shift-lifecycle step 7
5ShiftRefundedDomainEventShiftRefundConfirmer(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

ScenarioTerminal stateWhere it's decidedRecovery / runbook
Refund broadcast fails / on-chain tx revertsTransfer failed → Shift refunding → failedShiftTransferUpdaterReplace 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 refundingShiftTransferSender.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 createdShiftRefunder.run() returns early when senderRetriever returns null (ShiftRefunder.ts:45-50)Operator tops up an eligible sender; supervisor retries next cycle
Deposit transaction never confirmsShiftRefunder throws; shift stays in source stateisDepositTxConfirmed() guard (ShiftRefunder.ts:117-133)Manual — confirm the deposit or move the shift to failed
Refund attempted from non-refundable statusShiftRefunder throwsisRefundable() 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