Skip to content

TransactionStatus

Owner context: common (value object) — consumed by wallets for on-chain transactions | Entity: Transaction | Field: status | Values: 6

The lifecycle of a Transaction in the custodial wallets domain: from creation through broadcast to final settlement on-chain. Unlike ShiftStatus and TransferStatus, there is no explicit transition matrix on the value object — transitions are enforced only by guards inside the application services that mutate the entity. The happy path is pending → submitted → confirmed.

States

StateTerminalDescriptionEntry condition
pendingNoTransaction created, not yet broadcastInitial state (Transaction.create())
submittedNoBroadcast to the chain (or CEX), txHash known, awaiting settlementTransactionSender successful broadcast
confirmedYes (sink)Settled on-chainTransactionUpdater when provider reports confirmed
failedYes (recoverable)Settled on-chain as failed, or dropped from the mempool after timeoutTransactionUpdater (settled-failed or notOnChain + timeout)
cancelledYes (sink)Aborted before broadcastTransactionCanceller on a pending tx
notOnChainA value the provider port can return; not a state the entity persists under normal flowSee note below

notOnChain is special. It is a value the blockchain provider may return when polling, but Transaction.status is never stored as notOnChain by any service in the current codebase. The relevant logic is in TransactionUpdater.ts:63-70: if the provider reports notOnChain and the transaction is past its settlement window (isWaitingForSettlement() returns false), the entity is moved to failed. Otherwise the entity stays in submitted and will be polled again. Treat notOnChain as a provider signal, not a resting state.

Recoverable means the admin can still act on the state (replace via CPFP/RBF for EVM or UTXO chains, when isReplaceable() returns true); it does not mean the entity will automatically move again.

Transitions

Transition Table

There is no TRANSITIONS matrix on the value object. The transitions below are the ones reachable in practice via the application services that mutate the entity; every other transition is simply never called.

FromToTrigger (service)Invariant / guardSide effects
pendingsubmittedTransactionSenderGuard: must be isPending(); broadcast succeedsSets txHash, sentAt; emits TransactionSubmittedDomainEvent
pendingcancelledTransactionCancellerGuard: must be isPending()
submittedconfirmedTransactionUpdaterProvider returns status with isSettled() && is('confirmed')Sets fee; emits TransactionConfirmedDomainEvent
submittedfailedTransactionUpdater (settled path)Provider returns isSettled() && is('failed')Sets fee; emits TransactionFailedDomainEvent
submittedfailedTransactionUpdater (timeout path)Provider returns notOnChain and !transaction.isWaitingForSettlement()Emits TransactionFailedDomainEvent

A failed broadcast inside TransactionSender does not move the status — it emits TransactionBalanceInsufficientDomainEvent while the entity stays in pending (TransactionSender.ts).

Invariants

  1. Initial state is always pendingTransaction.create() hardcodes it (Transaction.ts:54-76).
  2. No state-transition validation in the value object. The value object has no canTransition() or TRANSITIONS map. Guards live only in the services: TransactionSender checks isPending() before broadcasting; TransactionCanceller checks isPending() before cancelling. This is the looser model compared to ShiftStatus and TransferStatus.
  3. True sinks are confirmed and cancelled. Nothing in the codebase moves a transaction out of them.
  4. failed is quasi-terminal. No service moves a transaction out of failed, but isReplaceable() is true for failed (and for submitted) — this is the signal that an admin can create a replacement transaction via CPFP/RBF. Replacement does not mutate this transaction's status; it creates a new one.
  5. isSettled() excludes cancelled. Returns true only for confirmed || failed (TransactionStatus.ts:44-46). The distinction matters: a cancelled transaction was never broadcast, so it has no on-chain outcome.
  6. notOnChain is never persisted. The value is in the enum for completeness with the provider port, but TransactionUpdater converts it to either no-op (still waiting) or failed (past timeout). If you ever see status === 'notOnChain' persisted, that is a bug.
  7. Settlement timeout is per-chain. Transaction.isWaitingForSettlement() returns true inside the settlement window (2h for UTXO, 20 min for other chains) based on sentAt (Transaction.ts:151-157). Past that, notOnChain from the provider promotes to failed.
  8. No direct event bridge to shifts. TransactionConfirmedDomainEvent is consumed by WalletUpdaterOnTransactionSettled to refresh the wallet balance, not to move a Transfer. Transfer state advancement polls Transaction.status through TransferWithdrawStatusRetriever instead.

Cross-context effect: Transaction → Wallet (and indirectly Transfer)

Transaction transitionDownstream effect
pending → submittedTransactionSubmittedDomainEvent — subscribed by wallet-facing updaters; sets txHash on the entity so downstream consumers (polling) can resolve it
submitted → confirmedTransactionConfirmedDomainEvent — consumed by WalletUpdaterOnTransactionSettled to trigger a delayed wallet refresh job. No event subscriber drives Transfer forward; that happens via polling.
submitted → failedTransactionFailedDomainEvent — same wallet refresh path; no direct Transfer effect

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

Code Pointers