ShiftStatus
Owner context: shifts | Entity: Shift | Field: status | Values: 12
The lifecycle of a shift (exchange transaction): from creation through deposit, exchange, transfer, and final state. Every shift starts at waiting and ends at a terminal state — the happy path is waiting → received → exchanging → confirming → confirmed, with recovery branches for failures, expirations, and refunds.
States
| State | Terminal | Description | Entry condition |
|---|---|---|---|
waiting | No | Shift created, awaiting deposit on the user's address | Initial state (Shift.create()) |
received | No | Deposit detected on-chain, not yet confirmed enough | ShiftDepositDetector finds matching tx |
exchanging | No | Deposit confirmed, exchange leg initiated at the CEX | ShiftDepositConfirmer after enough confirmations |
confirming | No | Outbound transfer created, awaiting chain confirmation | ShiftLooseTransferCreator / ShiftPreciseTransferCreator |
refunding | No | Refund transfer created, awaiting confirmation | ShiftRefunder |
confirmed | Yes (sink) | Happy path completed: user received funds | ShiftConfirmer on transfer confirmed |
refunded | Yes (sink) | Refund completed | ShiftRefundConfirmer on refund transfer confirmed |
paused | Yes (sink) | Manually paused by admin | ShiftStatusUpdateController with force=true |
failed | Yes (recoverable) | Failure (invalid deposit, transfer error, etc.) | Any active state on error; admin can push to expired or refunding |
expired | Yes (recoverable) | TTL elapsed without deposit, or admin recovery | ShiftDepositDetector timeout; admin unsuspend |
suspended | Yes (recoverable) | Held by KYT / compliance | Blocked address detected; admin ShiftUnsuspender can release |
banned | Yes (recoverable) | Banned by abuse / compliance | Admin force=true; only refunding allowed out |
Sink means TRANSITIONS has no key for that state — nothing moves it. Recoverable means isFinal() === true but the matrix still allows specific outgoing transitions (admin recovery, refund, unsuspend).
Transitions
Transition Table
Source of truth: ShiftStatus.TRANSITIONS at packages/shifts/domain/valueObjects/ShiftStatus.ts:21-33.
| From | To | Trigger (service / command) | Invariant | Side effects |
|---|---|---|---|---|
waiting | received | ShiftDepositDetector detects deposit | depositTxId must be set | Emits ShiftReceivedDomainEvent |
waiting | expired | ShiftDepositDetector timeout | No deposit detected within TTL | Emits ShiftExpiredDomainEvent |
waiting | failed | ShiftDepositDetector on invalid deposit | — | Emits ShiftFailedDomainEvent |
waiting | suspended | KYT blocks deposit address | Blocked address present | — |
received | exchanging | ShiftDepositConfirmer after N confirmations | depositTxId set | Emits ShiftExchangingDomainEvent |
received | failed | ShiftDepositConfirmer on deposit invalidation | — | Emits ShiftFailedDomainEvent |
received | refunding | ShiftRefunder (manual / admin) | Shift must be refundable | Emits ShiftRefundingDomainEvent + TransferCreatedDomainEvent |
exchanging | confirming | ShiftLooseTransferCreator / ShiftPreciseTransferCreator | Outbound transfer created | Emits ShiftConfirmingDomainEvent + TransferCreatedDomainEvent |
exchanging | refunding | ShiftRefunder | State in refundableStatuses | Emits ShiftRefundingDomainEvent |
exchanging | failed | Exchange error | — | Emits ShiftFailedDomainEvent |
confirming | confirmed | ShiftConfirmer on transfer confirmed | transferId must exist and be confirmed | Emits ShiftConfirmedDomainEvent; sets confirmedAt |
confirming | failed | KYT/transfer error | — | Emits ShiftFailedDomainEvent |
confirming | suspended | Compliance flag | — | — |
refunding | refunded | ShiftRefundConfirmer on refund confirmed | Refund transfer confirmed | Emits ShiftRefundedDomainEvent; sets refundedAt |
refunding | failed | Refund transfer failed | — | Emits ShiftFailedDomainEvent |
expired | received | Admin push (recovery) | depositTxId must be set | Emits FailedShiftHasBeenUpdatedDomainEvent |
expired | exchanging | Admin push (recovery) | depositTxId must be set | Emits FailedShiftHasBeenUpdatedDomainEvent |
expired | failed | Admin force | — | — |
expired | refunding | ShiftRefunder | Refundable | Emits ShiftRefundingDomainEvent |
suspended | expired | ShiftUnsuspender (KYT cleared) | No blocked addresses | — |
suspended | refunding | ShiftRefunder | Refundable | Emits ShiftRefundingDomainEvent |
failed | expired | Admin recovery | — | Emits FailedShiftHasBeenUpdatedDomainEvent |
failed | refunding | ShiftRefunder | Refundable | Emits ShiftRefundingDomainEvent |
banned | refunding | ShiftRefunder (mandatory refund) | Refundable | Emits ShiftRefundingDomainEvent |
Any transition not listed here is rejected by Shift.transitionTo() with InvalidShiftTransitionError. Admin overrides use Shift.forceTransitionTo() which bypasses TRANSITIONS validation (use sparingly).
Invariants
- Initial state is always
waiting—Shift.create()hardcodes it (Shift.ts:117). depositTxIdrequired beforereceivedorexchanging— enforced inShift.transitionTo()at Shift.ts:242-244; violation throwsMissingDepositError.transferIdrequired beforeconfirmed— enforced in transfer pipeline beforeShiftConfirmerruns.- Refundable statuses are exactly
[exchanging, expired, failed, suspended, banned](ShiftStatus.refundableStatuses, ShiftStatus.ts:105-111). Any refund attempt from another state fails validation inShiftRefunder. - In-progress statuses are
[waiting, received, exchanging, confirming, refunding](ShiftStatus.inProgressStatuses, ShiftStatus.ts:97-103); used byShiftStatusValidatorto filter active shifts. isFinal()vs true sinks —isFinal()returns true for[confirmed, failed, refunded, expired, suspended](ShiftStatus.ts:123-131), but theTRANSITIONSmatrix still allows outgoing transitions forfailed,expired, andsuspended(admin recovery, refund, unsuspend). TreatisFinal()as "the shift has reached a conclusion", not "this state never moves".- True sinks (no outgoing transitions in
TRANSITIONS) areconfirmed,refunded, andpaused. Notepausedis not inisFinal()— that is a known discrepancy; if this matters for a consumer, check bothisFinal()and the transition matrix. bannedis marked final-by-convention but has one outgoing edge:banned → refunding. Refund is mandatory for banned shifts.
Code Pointers
- Enum and matrix: packages/shifts/domain/valueObjects/ShiftStatus.ts
- Transition enforcement: packages/shifts/domain/entities/Shift.ts (
transitionTo,forceTransitionTo,setConfirmed,setRefunded) - Deposit detection: packages/shifts/application/services/ShiftDepositDetector.ts
- Deposit confirmation: packages/shifts/application/services/ShiftDepositConfirmer.ts
- Transfer creators: packages/shifts/application/services/transfers/ShiftLooseTransferCreator.ts,
ShiftPreciseTransferCreator.ts - Shift confirmation: packages/shifts/application/services/ShiftConfirmer.ts
- Refund pipeline: packages/shifts/application/services/ShiftRefunder.ts,
ShiftRefundConfirmer.ts - Unsuspend: packages/shifts/application/services/ShiftUnsuspender.ts
- Admin controller (force overrides): packages/shifts/infrastructure/controllers/ShiftStatusUpdateController.ts
- Active-shift filter: packages/shifts/application/services/ShiftStatusValidator.ts
- Test suite: packages/shifts/test/domain/valueObjects/ShiftStatus.test.ts
Related
- Owner context:
shifts - Sibling state machine on the transfer leg:
TransferStatus - Cross-context flow that drives most transitions:
shift-lifecycle - Refund branch of the lifecycle:
shift-refund— refundable statuses areexchanging, expired, failed, suspended, banned(ShiftRefunder).
