From 7af5980e0dc9b23246c5e114c89e9c66492fbfcf Mon Sep 17 00:00:00 2001 From: Suzan-max Date: Mon, 31 Aug 2026 06:30:13 +0100 Subject: [PATCH] fix: resolve issues #240 #241 #244 #245 #240 - IPFS pin content: add explicit 10 MB body size limit in main.ts (express.json/urlencoded with 15 MB limit for base64 overhead), @MaxLength on PinContentDto.content, and a Buffer.length guard in IpfsPinningService. BODY_LIMIT_MB env var documented in .env.example. #241 - WebhookService: add WEBHOOK_TIMEOUT_MS-configurable req.setTimeout so unresponsive endpoints abort after a bounded timeout instead of hanging indefinitely. Default 10s; documented in .env.example. #244 - STELLAR_NETWORK: align canonical value to Stellar SDK convention (PUBLIC instead of MAINNET). MAINNET accepted as legacy alias. Unrecognized values now throw at startup rather than silently defaulting to testnet. .env.example updated with accepted values and comments. #245 - Document off-chain state model in docs/state-model.md: state diagrams and transition tables for Escrow, Gig, and DisputeSaga; EventProcessorService on-chain event mapping; background worker responsibilities; and known deviations (EventProcessorService direct mutations, applyPayout bypassing guards, unused PENDING step, escrow_created ID mismatch). Linked from README. --- .env.example | 12 + README.md | 6 + backend/src/ipfs-pinning/ipfs-pinning.dto.ts | 8 +- .../src/ipfs-pinning/ipfs-pinning.service.ts | 10 + backend/src/main.ts | 11 + backend/src/stellar/stellar.config.ts | 39 ++- backend/src/webhook/webhook.service.ts | 10 + docs/state-model.md | 244 ++++++++++++++++++ 8 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 docs/state-model.md diff --git a/.env.example b/.env.example index 8856719..ec0e38c 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# Accepted values: TESTNET or PUBLIC (Stellar SDK canonical name for mainnet). +# "MAINNET" is also accepted as a legacy alias for "PUBLIC". +# An unrecognized value causes the app to fail fast at startup. STELLAR_NETWORK=TESTNET STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org SOROBAN_RPC_URL=https://soroban-testnet.stellar.org @@ -46,6 +49,15 @@ INFURA_IPFS_PROJECT_ID= INFURA_IPFS_PROJECT_SECRET= IPFS_REPIN_INTERVAL_MS=300000 +# Maximum request body size for JSON payloads (in MB). +# The IPFS pin endpoint accepts base64-encoded content; 15 MB provides headroom +# for a 10 MB decoded deliverable (~13.6 MB base64) plus JSON envelope overhead. +BODY_LIMIT_MB=15 + +# Webhook outgoing request timeout (ms). Requests that exceed this are aborted +# rather than hanging indefinitely. Defaults to 10 seconds. +WEBHOOK_TIMEOUT_MS=10000 + # Gig expiry sweep β€” marks unanswered gig solicitations expired past their response deadline GIG_EXPIRY_SWEEP_INTERVAL_MS=300000 # How long a paginated GET /gigs search result page stays cached in Redis diff --git a/README.md b/README.md index 5f8cbb5..421b604 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,12 @@ See [Backend Setup Instructions](backend/SETUP_INSTRUCTIONS.md) for detailed dev Full guide: [API Documentation](backend/API_DOCUMENTATION.md) +### πŸ—‚οΈ Off-Chain State Model + +[docs/state-model.md](docs/state-model.md) β€” Reference for the Escrow, Gig, and DisputeSaga state +machines: which transitions are driven by API calls, on-chain Soroban events, or background workers, +plus a catalogue of known deviations from the intended model. + ### Auth (`/auth`) - **`GET /auth/challenge`** β€” Get authentication challenge for wallet signing diff --git a/backend/src/ipfs-pinning/ipfs-pinning.dto.ts b/backend/src/ipfs-pinning/ipfs-pinning.dto.ts index b3782c2..a83ee9d 100644 --- a/backend/src/ipfs-pinning/ipfs-pinning.dto.ts +++ b/backend/src/ipfs-pinning/ipfs-pinning.dto.ts @@ -20,11 +20,17 @@ import { export class PinContentDto { @ApiProperty({ - description: 'Base64-encoded bytes of the deliverable to pin', + description: + 'Base64-encoded bytes of the deliverable to pin. ' + + 'Maximum decoded size is 10 MB (base64 overhead adds ~33%, so the encoded string cap is ~13.6 MB, ' + + 'enforced here as 14,316,560 base64 characters).', example: 'SGVsbG8sIFRydXN0RmxvdyE=', }) @IsBase64() @IsNotEmpty() + @MaxLength(14_316_560, { + message: 'content exceeds the maximum allowed size of 10 MB (decoded)', + }) content: string; @ApiPropertyOptional({ description: 'Original filename, stored for display purposes only' }) diff --git a/backend/src/ipfs-pinning/ipfs-pinning.service.ts b/backend/src/ipfs-pinning/ipfs-pinning.service.ts index cdce42f..68fab55 100644 --- a/backend/src/ipfs-pinning/ipfs-pinning.service.ts +++ b/backend/src/ipfs-pinning/ipfs-pinning.service.ts @@ -63,6 +63,16 @@ export class IpfsPinningService { */ async pinContent(dto: PinContentDto): Promise { const buffer = Buffer.from(dto.content, 'base64'); + + // Explicit size guard (belt-and-suspenders alongside the DTO @MaxLength check and + // the Express body-size limit configured in main.ts). + const MAX_BYTES = 10 * 1024 * 1024; // 10 MB + if (buffer.length > MAX_BYTES) { + throw new BadRequestException( + `Decoded content size (${buffer.length} bytes) exceeds the maximum allowed size of 10 MB`, + ); + } + const cid = computeCidV1Raw(buffer); if (dto.expectedCid && dto.expectedCid !== cid) { diff --git a/backend/src/main.ts b/backend/src/main.ts index 337bdec..3fcba24 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { ValidationPipe, Logger } from '@nestjs/common'; import * as Sentry from '@sentry/node'; +import * as express from 'express'; import { AppModule } from './app.module'; import { SentryService } from './sentry/sentry.service'; import { SentryExceptionFilter } from './common/filters/sentry-exception.filter'; @@ -29,6 +30,16 @@ process.on('uncaughtException', (error: Error) => { async function bootstrap() { const app = await NestFactory.create(AppModule); + // Explicit body-size limit for JSON payloads. + // The IPFS pin endpoint accepts base64-encoded deliverable content; 10 MB decoded + // encodes to ~13.6 MB base64, so a 15 MB JSON limit gives adequate headroom while + // still providing a deliberate, reviewed DoS control rather than relying on Express's + // implicit default. Override via BODY_LIMIT_MB env var if your use case requires it. + const bodyLimitMb = parseInt(process.env.BODY_LIMIT_MB || '15', 10); + const bodyLimit = `${bodyLimitMb}mb`; + app.use(express.json({ limit: bodyLimit })); + app.use(express.urlencoded({ extended: true, limit: bodyLimit })); + // Initialize Sentry via the injectable service so it shares the same instance const sentryService = app.get(SentryService); sentryService.init(); diff --git a/backend/src/stellar/stellar.config.ts b/backend/src/stellar/stellar.config.ts index 60b378c..abcfb13 100644 --- a/backend/src/stellar/stellar.config.ts +++ b/backend/src/stellar/stellar.config.ts @@ -1,5 +1,37 @@ +/** + * Canonical accepted values for STELLAR_NETWORK: + * - "TESTNET" β€” Stellar Testnet (Test SDF Network ; September 2015) + * - "PUBLIC" β€” Stellar Mainnet, matching @stellar/stellar-sdk's Networks.PUBLIC constant + * + * The legacy value "MAINNET" is also accepted as an alias for "PUBLIC" to avoid breaking + * existing deployments, but "PUBLIC" is the preferred production value per Stellar SDK conventions. + * + * An unrecognized value causes the app to throw at startup rather than silently + * defaulting to testnet (which would be a dangerous misconfiguration in production). + */ +const rawNetwork = process.env.STELLAR_NETWORK || 'TESTNET'; + +// Accept "MAINNET" as an alias for "PUBLIC" (legacy compatibility) +const normalizedNetwork = rawNetwork === 'MAINNET' ? 'PUBLIC' : rawNetwork; + +const VALID_NETWORKS = ['TESTNET', 'PUBLIC'] as const; +type StellarNetworkValue = (typeof VALID_NETWORKS)[number]; + +if (!(VALID_NETWORKS as readonly string[]).includes(normalizedNetwork)) { + throw new Error( + `Invalid STELLAR_NETWORK value: "${rawNetwork}". ` + + `Accepted values are "TESTNET" or "PUBLIC" (the Stellar SDK canonical name for mainnet). ` + + `"MAINNET" is also accepted as a legacy alias for "PUBLIC".`, + ); +} + +const NETWORK_PASSPHRASES: Record = { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', +}; + export const STELLAR_CONFIG = { - network: process.env.STELLAR_NETWORK || 'TESTNET', + network: normalizedNetwork as StellarNetworkValue, // Single endpoint fallback (for backward compatibility) horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org', sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org', @@ -11,8 +43,5 @@ export const STELLAR_CONFIG = { .split(',') .map(url => url.trim()), contractId: process.env.TRUSTFLOW_CONTRACT_ID || '', - networkPassphrase: - process.env.STELLAR_NETWORK === 'MAINNET' - ? 'Public Global Stellar Network ; September 2015' - : 'Test SDF Network ; September 2015', + networkPassphrase: NETWORK_PASSPHRASES[normalizedNetwork as StellarNetworkValue], }; diff --git a/backend/src/webhook/webhook.service.ts b/backend/src/webhook/webhook.service.ts index 0c39b89..10fff79 100644 --- a/backend/src/webhook/webhook.service.ts +++ b/backend/src/webhook/webhook.service.ts @@ -10,6 +10,13 @@ interface WebhookPayload { dedupKey?: string; } +/** + * Timeout (ms) for outgoing webhook HTTP requests. + * Configurable via WEBHOOK_TIMEOUT_MS env var; defaults to 10 seconds. + * Prevents a single unresponsive endpoint from stalling dispatch() indefinitely. + */ +const WEBHOOK_TIMEOUT_MS = parseInt(process.env.WEBHOOK_TIMEOUT_MS || '10000', 10); + @Injectable() export class WebhookService { private endpoints = new Map(); @@ -64,6 +71,9 @@ export class WebhookService { else rej(new Error(`${r.statusCode}`)); }, ); + req.setTimeout(WEBHOOK_TIMEOUT_MS, () => { + req.destroy(new Error(`Webhook request timed out after ${WEBHOOK_TIMEOUT_MS}ms`)); + }); req.on('error', rej); req.write(body); req.end(); diff --git a/docs/state-model.md b/docs/state-model.md new file mode 100644 index 0000000..484527e --- /dev/null +++ b/docs/state-model.md @@ -0,0 +1,244 @@ +# Off-Chain ↔ On-Chain State Model + +> **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. + +--- + +## 1. Escrow + +**Source**: `src/escrow/escrow.service.ts` +**Type**: `EscrowStatus = 'pending' | 'active' | 'released' | 'disputed' | 'cancelled'` + +### State Diagram + +``` + 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) +``` + +### Transition Table + +| 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()` | + +### Known Deviations + +- **`EventProcessorService` mutates `Escrow` directly** β€” `handleEscrowFunded`, `handleEscrowReleased`, + and `handleEscrowDisputed` call `EscrowService.fund/release/raiseDispute` in 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). + +- **`applyChainState` bypasses 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** β€” unlike `raiseDispute()`, `EscrowService.release()` + does not verify the current status before transitioning. An already-released or cancelled escrow + can be re-released via API without error. + +--- + +## 2. Gig + +**Source**: `src/gig/gig.entity.ts`, `src/gig/gig.service.ts`, `src/gig/gig-expiry-worker.service.ts` +**Type**: `GigStatus = 'open' | 'accepted' | 'expired' | 'cancelled'` + +### State Diagram + +``` + 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 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Transition Table + +| 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 | + +### Notes + +- 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 all `open` gigs whose `respondBy` timestamp has passed. + +--- + +## 3. DisputeSaga / DisputeStep + +**Source**: `src/dispute/dispute.types.ts`, `src/dispute/dispute-saga.service.ts` +**Types**: `DisputeStep`, `DisputeVerdict` + +### State Diagram + +``` + 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 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Transition Table + +| 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()` | + +### DisputeVerdict β†’ Escrow Status Mapping + +| Verdict | Escrow outcome | Escrow status after payout | +|--------------------|----------------|----------------------------| +| `BENEFICIARY_WINS` | Full release | `released` | +| `DEPOSITOR_WINS` | Full cancel | `cancelled` | +| `SPLIT` | Partial split | `released` (+ splitPercentage) | + +### Known Deviations + +- **`applyPayout` mutates `Escrow` directly** β€” `DisputeSagaService.applyPayout()` calls + `EscrowService.release()`, `cancel()`, or `split()` directly. These calls bypass the + `disputed β†’ released/cancelled` guard logic that would be expected in a clean state machine, + because `release()` has no precondition check (see Escrow deviations above). + +- **`compensateEscalation` mutates `escrow.status` inline** β€” the compensating action for a + failed escalation writes `escrow.status = 'active'` directly on the in-memory object rather + than going through `EscrowService.applyChainState()`. This bypasses the service layer entirely. + +- **`compensatePayout` flags escrow for manual review inline** β€” casts `escrow` to an ad-hoc + extended type to set `requiresManualReview = true`. This property is not declared on the + `Escrow` interface and will be lost on any serialization. + +- **`PENDING` step in `DisputeStep` enum is unused** β€” `DisputeStep.PENDING` is declared in + `dispute.types.ts` but never set by `DisputeSagaService`. Sagas start at `ESCALATION`. + +--- + +## 4. EventProcessorService β€” On-Chain Event Mapping + +**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` | + +### Gaps + +- `escrow_created` events create a **new row with a random UUID**, not linked by `contractEscrowId`. + The reconciler (`EscrowReconciliationService`) has a separate `createFromChainState()` path for + that, but `EventProcessorService` does not use it β€” so events and reconciler writes can produce + duplicate rows for the same on-chain escrow. + +- `escrow_funded/released/disputed` use `event.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 `EventProcessorService` and `DisputeSagaService`** β€” both + can independently move an escrow to `disputed`. A `dispute.escalate` API call and an incoming + `escrow_disputed` event for the same escrow will both attempt `raiseDispute()`, with the second + call throwing a `"Escrow is already disputed"` error (which `EventProcessorService` records as a + failed event rather than propagating). + +--- + +## 5. Background Workers + +| 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 |