Reference for issue #245. This document describes the current (already-implemented) off-chain state machines for Escrow, Gig, and DisputeSaga, what drives each transition, and where the off-chain model currently deviates from a clean event-sourced design.
Source: src/escrow/escrow.service.ts
Type: EscrowStatus = 'pending' | 'active' | 'released' | 'disputed' | 'cancelled'
API: POST /escrow
│
▼
┌─────────┐
│ pending │
└────┬────┘
│ API: POST /escrow/:id/fund
│ OR on-chain event: escrow_funded (EventProcessorService)
▼
┌────────┐
│ active │◄─────────────────────────────────────┐
└───┬────┘ │
│ │
┌──────────┼──────────────┐ Compensating: revert to 'active'
│ │ │ (DisputeSagaService.compensateEscalation)
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌───────────┐
│ released │ │cancelled │ │ disputed │
└──────────┘ └──────────┘ └─────┬─────┘
│
DisputeSagaService.applyPayout resolves:
• BENEFICIARY_WINS → released
• DEPOSITOR_WINS → cancelled
• SPLIT → released (with splitPercentage)
| From | To | Trigger | Source |
|---|---|---|---|
| — | pending |
POST /escrow (API call) |
EscrowService.create() |
pending |
active |
POST /escrow/:id/fund (API call) |
EscrowService.fund() |
pending |
active |
On-chain event escrow_funded |
EventProcessorService.handleEscrowFunded() |
active |
released |
POST /escrow/:id/release (API call) |
EscrowService.release() |
active |
released |
On-chain event escrow_released |
EventProcessorService.handleEscrowReleased() |
active |
disputed |
POST /escrow/:id/dispute (API call) |
EscrowService.raiseDispute() |
active |
disputed |
On-chain event escrow_disputed |
EventProcessorService.handleEscrowDisputed() |
active |
disputed |
DisputeSagaService.escalate() (internal, via API) |
EscrowService.raiseDispute() |
active |
cancelled |
POST /escrow/:id/cancel (API call) |
EscrowService.cancel() |
disputed |
released |
Saga payout — verdict BENEFICIARY_WINS or SPLIT | DisputeSagaService.applyPayout() |
disputed |
cancelled |
Saga payout — verdict DEPOSITOR_WINS | DisputeSagaService.applyPayout() |
disputed |
active |
Compensating rollback on failed escalation | DisputeSagaService.compensateEscalation() |
| any | any | Reconciler drift correction | EscrowService.applyChainState() |
-
EventProcessorServicemutatesEscrowdirectly —handleEscrowFunded,handleEscrowReleased, andhandleEscrowDisputedcallEscrowService.fund/release/raiseDisputein exactly the same way as the API controllers. There is no coordination layer; if both an API call and an on-chain event arrive for the same escrow in a short window, one will silently overwrite the other or throw (depending on current status guard state). -
applyChainStatebypasses all guards — the reconciler can write any status directly to any escrow. This is intentional for drift-repair, but it means the state machine can be put into states that no normal transition would produce. -
release()has no precondition check — unlikeraiseDispute(),EscrowService.release()does not verify the current status before transitioning. An already-released or cancelled escrow can be re-released via API without error.
Source: src/gig/gig.entity.ts, src/gig/gig.service.ts, src/gig/gig-expiry-worker.service.ts
Type: GigStatus = 'open' | 'accepted' | 'expired' | 'cancelled'
API: POST /gigs
│
▼
┌──────┐
│ open │
└──┬───┘
│
┌────┴────────────┬─────────────────────────┐
│ │ │
│ API: │ API: │ Background worker:
│ POST /gigs/:id/ │ DELETE /gigs/:id │ GigExpiryWorkerService sweep
│ accept │ (cancel) │ (every GIG_EXPIRY_SWEEP_INTERVAL_MS)
▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌─────────┐
│ accepted │ │ cancelled │ │ expired │
└──────────┘ └───────────┘ └─────────┘
| From | To | Trigger | Source |
|---|---|---|---|
| — | open |
POST /gigs (API call) |
GigService.create() |
open |
accepted |
POST /gigs/:id/accept (API call) |
GigService.accept() |
open |
cancelled |
DELETE /gigs/:id (API call) |
GigService.cancel() |
open |
expired |
Background sweep past respondBy deadline |
GigExpiryWorkerService sweep |
- Gig status is entirely off-chain — there is no corresponding on-chain Soroban contract state for gigs in the current implementation.
- The expiry sweep runs on a configurable interval (
GIG_EXPIRY_SWEEP_INTERVAL_MS, default 5 min) and marks allopengigs whoserespondBytimestamp has passed.
Source: src/dispute/dispute.types.ts, src/dispute/dispute-saga.service.ts
Types: DisputeStep, DisputeVerdict
POST /dispute/:escrowId/escalate (API)
│
▼
┌────────────┐
│ ESCALATION │──── (failure) ──────────────────────────────┐
└─────┬──────┘ │
│ success │
▼ │
┌──────────────────┐ │
│ JUROR_ASSIGNMENT │──── (failure) ───────────────────────┐ │
└────────┬─────────┘ │ │
│ POST /dispute/:sagaId/jurors (API) │ │
▼ │ │
┌────────┐ │ │
│ VOTING │──── (failure) ───────────────────────┐ │ │
└───┬────┘ │ │ │
│ POST /dispute/:sagaId/vote (API, per juror) │ │ │
│ [all jurors voted → verdict computed] │ │ │
▼ │ │ │
┌────────┐ │ │ │
│ PAYOUT │──── (failure) ─────────────────┐ │ │ │
└───┬────┘ │ │ │ │
│ POST /dispute/:sagaId/payout (API) │ │ │ │
▼ │ │ │ │
┌───────────┐ ▼ ▼ ▼ ▼
│ COMPLETED │ ┌─────────────────────┐
└───────────┘ │ COMPENSATING │
└──────────┬──────────┘
│
▼
┌────────┐
│ FAILED │
└────────┘
| From | To | Trigger | Source |
|---|---|---|---|
| — | ESCALATION |
POST /dispute/:escrowId/escalate (API) |
DisputeSagaService.escalate() |
ESCALATION |
JUROR_ASSIGNMENT |
Escalation step completes successfully | DisputeSagaService.escalate() |
JUROR_ASSIGNMENT |
VOTING |
POST /dispute/:sagaId/jurors (API) |
DisputeSagaService.assignJurors() |
VOTING |
PAYOUT |
All jurors have voted (majority verdict set) | DisputeSagaService.castVote() |
PAYOUT |
COMPLETED |
POST /dispute/:sagaId/payout (API) |
DisputeSagaService.executePayout() |
| any | COMPENSATING |
Step throws an error | DisputeSagaService.compensate*() |
COMPENSATING |
FAILED |
Compensation recorded | DisputeSagaService.markFailed() |
| Verdict | Escrow outcome | Escrow status after payout |
|---|---|---|
BENEFICIARY_WINS |
Full release | released |
DEPOSITOR_WINS |
Full cancel | cancelled |
SPLIT |
Partial split | released (+ splitPercentage) |
-
applyPayoutmutatesEscrowdirectly —DisputeSagaService.applyPayout()callsEscrowService.release(),cancel(), orsplit()directly. These calls bypass thedisputed → released/cancelledguard logic that would be expected in a clean state machine, becauserelease()has no precondition check (see Escrow deviations above). -
compensateEscalationmutatesescrow.statusinline — the compensating action for a failed escalation writesescrow.status = 'active'directly on the in-memory object rather than going throughEscrowService.applyChainState(). This bypasses the service layer entirely. -
compensatePayoutflags escrow for manual review inline — castsescrowto an ad-hoc extended type to setrequiresManualReview = true. This property is not declared on theEscrowinterface and will be lost on any serialization. -
PENDINGstep inDisputeStepenum is unused —DisputeStep.PENDINGis declared indispute.types.tsbut never set byDisputeSagaService. Sagas start atESCALATION.
Source: src/event-ingestion/event-processor.service.ts
| Soroban event type | Off-chain action | Notes |
|---|---|---|
escrow_created |
EscrowService.create(depositor, beneficiary, amount) |
Creates a new DB row; no deduplication against existing contract-linked rows |
escrow_funded |
EscrowService.fund(escrowId) |
Uses event.topic[1] as off-chain ID; may fail if ID not found |
escrow_released |
EscrowService.release(escrowId) |
Same ID assumption as above |
escrow_disputed |
EscrowService.raiseDispute(escrowId, reason) |
Same ID assumption; reason from event.value.reason |
-
escrow_createdevents create a new row with a random UUID, not linked bycontractEscrowId. The reconciler (EscrowReconciliationService) has a separatecreateFromChainState()path for that, butEventProcessorServicedoes not use it — so events and reconciler writes can produce duplicate rows for the same on-chain escrow. -
escrow_funded/released/disputeduseevent.topic[1]as the off-chain UUID. In practice this would be the on-chain contract escrow ID, not the off-chain UUID — these handlers will fail to find any matching row unless the IDs happen to match, which they do not by default. -
There is no reconciliation between
EventProcessorServiceandDisputeSagaService— both can independently move an escrow todisputed. Adispute.escalateAPI call and an incomingescrow_disputedevent for the same escrow will both attemptraiseDispute(), with the second call throwing a"Escrow is already disputed"error (whichEventProcessorServicerecords as a failed event rather than propagating).
| Worker | Source | What it drives |
|---|---|---|
GigExpiryWorkerService |
src/gig/gig-expiry-worker.service.ts |
open → expired for overdue gigs |
RepinWorkerService |
src/ipfs-pinning/repin-worker.service.ts |
Calls IpfsPinningService.reconcile() |
SorobanEventIndexerService |
src/soroban-event-indexer/soroban-event-indexer.service.ts |
Polls chain and feeds EventProcessorService |
EscrowReconciliationWorkerService |
src/escrow-reconciliation/escrow-reconciliation-worker.service.ts |
Diffs off-chain vs on-chain escrow state |