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 26d04f6..7529d11 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'; @@ -30,6 +31,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 |