This document is the formal lifecycle specification for the backend's EscrowState
enum, defined in prisma/schema.prisma and mirrored in
src/common/enums/escrow-state.enum.ts.
The on-chain contract (in the separate contracts/ repository) uses a smaller set
of states: Pending, Funded, Shipped, Disputed, Completed, Refunded,
Canceled. The backend extends this with three additional states to model
off-chain bookkeeping that the contract does not track:
| Backend state | Contract equivalent | Why it exists |
|---|---|---|
CREATED |
Pending |
Escrow record created in the DB before funding is confirmed on-chain. |
DELIVERED |
(none) | Carrier API reports delivery; used to start the 48-hour auto-release window. |
RELEASED |
(none) | On-chain auto-release event confirmed; distinct from COMPLETED (backend-initiated release). |
Name spelling also differs: the contract uses Canceled (one L), the backend uses
CANCELLED (two L's) to match Prisma's PostgreSQL enum conventions.
| State | Meaning | Terminal |
|---|---|---|
CREATED |
Escrow record created; buyer funds not yet locked on-chain. | No |
FUNDED |
Buyer funds locked in the contract (confirmed via Horizon webhook or chain sync). | No |
SHIPPED |
Seller marked the escrow shipped and stored a tracking ID. | No |
DELIVERED |
Carrier API reports delivery; 48-hour auto-release countdown begins. | No |
DISPUTED |
Buyer raised a dispute before the dispute deadline. | No |
COMPLETED |
Funds released to the seller by the backend auto-release worker or dispute resolution. | Yes |
RELEASED |
On-chain auto-release event confirmed (contract-initiated release). | Yes |
REFUNDED |
Funds returned to the buyer after dispute resolution. | Yes |
CANCELLED |
Escrow cancelled by buyer, seller, or admin while in CREATED or FUNDED state. |
Yes |
stateDiagram-v2
[*] --> CREATED: createEscrow
CREATED --> FUNDED: StellarWebhookService.handlePayment
CREATED --> CANCELLED: EscrowService.cancelPendingEscrow
FUNDED --> SHIPPED: EscrowService.handleShipment
FUNDED --> CANCELLED: EscrowService.cancelEscrow
FUNDED --> DISPUTED: syncStateFromChain (DisputeRaised)
SHIPPED --> DELIVERED: TrackingPollWorker
DELIVERED --> RELEASED: AutoReleaseWorker + AutoReleased event
SHIPPED --> DISPUTED: syncStateFromChain (DisputeRaised)
DELIVERED --> DISPUTED: syncStateFromChain (DisputeRaised)
DISPUTED --> COMPLETED: DisputeService.resolve (RELEASE)
DISPUTED --> REFUNDED: DisputeService.resolve (REFUND)
COMPLETED --> [*]
RELEASED --> [*]
REFUNDED --> [*]
CANCELLED --> [*]
| Current state | Next state | Trigger | Code location |
|---|---|---|---|
| (new record) | CREATED |
Escrow created by vendor | EscrowService.createEscrow |
CREATED |
FUNDED |
Buyer payment confirmed on-chain | StellarWebhookService.handlePayment |
CREATED |
FUNDED |
Chain sync event (fallback) | EscrowService.syncStateFromChain (EscrowFunded) |
CREATED |
CANCELLED |
Buyer, seller, or admin cancels | EscrowService.cancelPendingEscrow |
CREATED |
CANCELLED |
Auto-cancel event after timeout | EscrowService.syncStateFromChain (EscrowAutoCancelled) |
FUNDED |
SHIPPED |
Seller marks shipped with tracking ID | EscrowService.handleShipment |
FUNDED |
SHIPPED |
Chain sync event (fallback) | EscrowService.syncStateFromChain (EscrowShipped) |
FUNDED |
CANCELLED |
Buyer, seller, or admin cancels | EscrowService.cancelEscrow |
FUNDED |
REFUNDED |
Buyer reclaims funds (expired escrow) | EscrowService.syncStateFromChain (EscrowExpired) |
FUNDED |
DISPUTED |
Chain sync event (DisputeRaised) | EscrowService.syncStateFromChain (DisputeRaised) |
SHIPPED |
DELIVERED |
Carrier API reports delivery | TrackingPollWorker.run |
SHIPPED |
REFUNDED |
Seller approves refund on-chain | EscrowService.syncStateFromChain (RefundApproved) |
DELIVERED |
RELEASED |
Auto-release after 48h: worker submits, AutoReleased event finalises |
AutoReleaseWorker.run + EscrowService.syncStateFromChain (AutoReleased) |
SHIPPED |
DISPUTED |
Chain sync event (DisputeRaised) | EscrowService.syncStateFromChain (DisputeRaised) |
DELIVERED |
DISPUTED |
Chain sync event (DisputeRaised) | EscrowService.syncStateFromChain (DisputeRaised) |
DISPUTED |
COMPLETED |
Admin resolves with RELEASE | DisputeService.resolve |
DISPUTED |
REFUNDED |
Admin resolves with REFUND | DisputeService.resolve |
DISPUTED |
COMPLETED |
Chain sync event (DisputeResolved Release) |
EscrowService.syncStateFromChain (DisputeResolved) |
DISPUTED |
REFUNDED |
Chain sync event (DisputeResolved Refund) |
EscrowService.syncStateFromChain (DisputeResolved) |
CREATED |
SHIPPED |
Chain sync event (EscrowShipped after missed EscrowFunded) |
EscrowService.syncStateFromChain (EscrowShipped) |
| any non-terminal | COMPLETED |
Chain sync event (EscrowCompleted) | EscrowService.syncStateFromChain (EscrowCompleted) |
| any non-terminal | DISPUTED |
Chain sync event (DisputeRaised) | EscrowService.syncStateFromChain (DisputeRaised) |
| any non-terminal | RELEASED |
Chain sync event (AutoReleased) | EscrowService.syncStateFromChain (AutoReleased) |
| any non-terminal | CANCELLED |
Chain sync event (EscrowAutoCancelled) | EscrowService.syncStateFromChain (EscrowAutoCancelled) |
| any non-terminal | REFUNDED |
Chain sync event (EscrowExpired) | EscrowService.syncStateFromChain (EscrowExpired) |
| any non-terminal | REFUNDED |
Chain sync event (RefundApproved) | EscrowService.syncStateFromChain (RefundApproved) |
COMPLETED, RELEASED, REFUNDED, and CANCELLED are terminal — no further
state transitions are permitted from any of them. This is enforced by the
TERMINAL_STATES set at the top of src/escrow/escrow.service.ts.
- The Stellar Horizon webhook receives a payment matching the escrow's amount and asset code, addressed to the vendor's destination account.
- Alternatively, a chain sync
EscrowFundedevent arrives for the escrow.
- Caller must be the buyer, seller, or admin.
- Escrow must be in
CREATEDstate. - If on-chain state is
FUNDED, an on-chain refund is submitted first viacontractService.cancelEscrowOnChain()before marking cancelled.
- Caller must be the seller or admin.
- Escrow must be in
FUNDEDstate. - Must not already have a
trackingId. trackingIdis stored andshippedAtis set.
- Caller must be the buyer, seller, or admin.
- Escrow must be in
FUNDEDstate. - No on-chain refund is submitted (funds are still locked in the contract).
TrackingPollWorkerpolls the carrier API every 10 minutes.- When the carrier status is
DELIVERED,markDeliveredsetsdeliveredAtanddeliveryRecordedAt, and transitions toDELIVERED. - Also calls
contractService.recordDelivery()on-chain.
AutoReleaseWorkerpolls every 5 minutes.- Eligibility: state is
DELIVERED,deliveredAtis at leastAUTO_RELEASE_WINDOW_HOURS(48) ago, no dispute exists, no auto-release transaction has been submitted. - The state predicate must match what
markDeliveredwrites.markDeliveredis the only writer ofdeliveredAtand setsDELIVEREDin the same update, so any other state paired with a non-nulldeliveredAtmatches nothing and auto-release silently never fires (issue #395). - Uses an atomic
updateManyoptimistic lock (markAutoReleaseSubmitting) to prevent concurrent workers from double-submitting. - On success, calls
recordAutoReleaseSubmission, which records the transaction hash and leaves the state atDELIVERED. Submission is not confirmation. - The terminal transition belongs to the
AutoReleasedchain event, whichsyncStateFromChainturns intomarkAutoReleased(RELEASED) plus the completion notification.
- Caller must be an admin.
- Calls
contractService.resolveDispute()on-chain. - Updates the dispute record to
RESOLVED. - RELEASE: calls
escrowRepository.markCompleted()— funds go to seller. - REFUND: calls
escrowRepository.markRefunded()— funds go to buyer.
The blockchain listener forwards Soroban events to EscrowService.syncStateFromChain.
Each event type performs the corresponding state transition, skipping escrows already
in a terminal state or the target state:
| Event | Target state | Extra action |
|---|---|---|
EscrowFunded |
FUNDED |
Only from CREATED; later non-terminal states (SHIPPED, DELIVERED, DISPUTED) are skipped so late/redelivered events never move an escrow backwards |
EscrowShipped |
SHIPPED |
Stores trackingId; only from CREATED or FUNDED (DELIVERED and DISPUTED are skipped) |
EscrowCompleted |
COMPLETED |
— |
EscrowAutoCancelled |
CANCELLED |
Auto-cancel from contract (unfunded escrow timeout) |
EscrowExpired |
REFUNDED |
Buyer reclaims on expired FUNDED/SHIPPED escrow |
RefundApproved |
REFUNDED |
Seller approved refund, contract pays buyer |
DisputeRaised |
DISPUTED |
Creates a Dispute record |
DisputeResolved |
COMPLETED / REFUNDED |
Updates dispute status to RESOLVED; transitions to REFUNDED when resolution is REFUND, or COMPLETED when RELEASE |
AutoReleased |
RELEASED |
Records on-chain txHash (from RPC event id) |
COMPLETED,RELEASED,REFUNDED, andCANCELLEDare terminal states. No transitions are allowed from any of them.- Self-transitions are invalid — the code skips them silently.
CREATEDescrows cannot be disputed or completed.CANCELLEDescrows cannot be funded later.- A dispute must resolve to either seller release (
COMPLETED) or buyer refund (REFUNDED). - The backend auto-release worker and the on-chain auto-release event produce
different terminal states:
COMPLETED(worker) vsRELEASED(on-chain event). Both represent funds released to the seller.