From 4b0311821b885e6bb51faceabf4c1cfa17e8ac38 Mon Sep 17 00:00:00 2001 From: bigpoppaofny Date: Sun, 18 Oct 2026 15:23:52 +0100 Subject: [PATCH] =?UTF-8?q?=20PREWAVE-BE-007=20=E2=80=94=20Publish=20Dispu?= =?UTF-8?q?te,=20Appeal,=20and=20Settlement=20Read=20Models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 3 + .../dispute-read-model-freshness.service.ts | 234 +++++++ .../dispute-read-model-provenance.service.ts | 153 +++++ .../read-models/dispute-read-model-view.ts | 196 ++++++ .../dispute-read-model.controller.ts | 79 +++ .../read-models/dispute-read-model.cursor.ts | 63 ++ .../dispute-read-model.service.spec.ts | 478 +++++++++++++ .../read-models/dispute-read-model.service.ts | 371 ++++++++++ .../read-models/dispute-read-model.types.ts | 287 ++++++++ .../read-models/dispute-read-models.module.ts | 35 + .../dto/dispute-read-model-query.dto.ts | 53 ++ .../dto/dispute-read-model-response.dto.ts | 312 +++++++++ src/v2/disputes/read-models/dto/index.ts | 2 + src/v2/disputes/read-models/index.ts | 9 + .../contracts/dispute-read-model.fixtures.ts | 646 ++++++++++++++++++ .../contracts/dispute-read-model.spec.ts | 449 ++++++++++++ 16 files changed, 3370 insertions(+) create mode 100644 src/v2/disputes/read-models/dispute-read-model-freshness.service.ts create mode 100644 src/v2/disputes/read-models/dispute-read-model-provenance.service.ts create mode 100644 src/v2/disputes/read-models/dispute-read-model-view.ts create mode 100644 src/v2/disputes/read-models/dispute-read-model.controller.ts create mode 100644 src/v2/disputes/read-models/dispute-read-model.cursor.ts create mode 100644 src/v2/disputes/read-models/dispute-read-model.service.spec.ts create mode 100644 src/v2/disputes/read-models/dispute-read-model.service.ts create mode 100644 src/v2/disputes/read-models/dispute-read-model.types.ts create mode 100644 src/v2/disputes/read-models/dispute-read-models.module.ts create mode 100644 src/v2/disputes/read-models/dto/dispute-read-model-query.dto.ts create mode 100644 src/v2/disputes/read-models/dto/dispute-read-model-response.dto.ts create mode 100644 src/v2/disputes/read-models/dto/index.ts create mode 100644 src/v2/disputes/read-models/index.ts create mode 100644 test/fixtures/contracts/dispute-read-model.fixtures.ts create mode 100644 test/fixtures/contracts/dispute-read-model.spec.ts diff --git a/src/app.module.ts b/src/app.module.ts index 037dcb42..ce87b868 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -28,6 +28,7 @@ import { CacheModule } from './cache/cache.module'; import { ClaimsModule } from './claims/claims.module'; import { ClaimFeedModule } from './claims/v2/claim-feed.module'; import { ClaimReadModelsModule } from './claims/v2/read-models/claim-read-models.module'; +import { DisputeReadModelsModule } from './v2/disputes/read-models/dispute-read-models.module'; import { AuditModule } from './audit/audit.module'; import { ThemeModule } from './theme.module'; import { SupportModule } from './support/support.module'; @@ -344,6 +345,8 @@ async function createThrottlerStorage( ClaimFeedModule, // PREWAVE-BE-005: versioned claim list/detail read models ClaimReadModelsModule, + // PREWAVE-BE-007: versioned dispute register/detail read models + DisputeReadModelsModule, AuditModule, ThemeModule, SupportModule, diff --git a/src/v2/disputes/read-models/dispute-read-model-freshness.service.ts b/src/v2/disputes/read-models/dispute-read-model-freshness.service.ts new file mode 100644 index 00000000..2ac8c249 --- /dev/null +++ b/src/v2/disputes/read-models/dispute-read-model-freshness.service.ts @@ -0,0 +1,234 @@ +import { Injectable, Logger, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { EventCheckpoint } from '../../events/entities/event-checkpoint.entity'; +import { BlockchainStateService } from '../../../blockchain/state.service'; +import { ProjectDispute } from '../entities/project-dispute.entity'; +import { ReadModelFreshness } from './dispute-read-model.types'; + +/** + * Only reasons that make it unsafe to serve a read model are reported as + * stale. Informational gaps (e.g. no live indexer snapshot wired in) are + * tolerated because the durable finality checkpoint is authoritative; the + * read model still fails closed on any *missing or ambiguous* finality. + */ + +/** Default tolerated distance between the projection and the observed head. */ +const DEFAULT_MAX_LAG_BLOCKS = 500; + +export const DISPUTE_READ_MODEL_MAX_LAG_CONFIG_KEY = + 'DISPUTE_READ_MODEL_MAX_LAG_BLOCKS'; +export const DISPUTE_READ_MODEL_MAX_AGE_CONFIG_KEY = + 'DISPUTE_READ_MODEL_MAX_AGE_MS'; + +/** + * Derives the freshness/provenance-of-recency envelope for the dispute read + * models from durable state only: + * + * 1. `v2_event_checkpoints` — safe/finalized heights + last advance time, + * 2. `v2_project_dispute` — the highest projected dispute block, + * 3. an optional live indexer snapshot (observed head) when the process has + * one wired in. + * + * It never writes and never mutates protocol state. Missing finality is + * surfaced as a stale reason so the caller can fail closed rather than + * presenting provisional data as final. + */ +@Injectable() +export class DisputeReadModelFreshnessService { + private readonly logger = new Logger(DisputeReadModelFreshnessService.name); + + constructor( + @InjectRepository(EventCheckpoint) + private readonly checkpointRepo: Repository, + @InjectRepository(ProjectDispute) + private readonly disputeRepo: Repository, + private readonly configService: ConfigService, + @Optional() + private readonly blockchainState?: BlockchainStateService, + ) {} + + async snapshot(chainId: number): Promise { + const reasons = new Set(); + + const checkpoints = await this.readCheckpoints(chainId, reasons); + const { safeBlock, finalizedBlock, lastSuccess } = + this.aggregateCheckpoints(checkpoints); + + const indexedBlock = await this.readIndexedBlock(chainId, reasons); + const snapshot = await this.readIndexerSnapshot(); + + const observedHead = + snapshot && snapshot.observedHeadBlock > 0 + ? snapshot.observedHeadBlock + : null; + + const lagBlocks = this.computeLag(observedHead, indexedBlock); + const reorgDetected = + observedHead !== null && + indexedBlock !== null && + BigInt(indexedBlock) > BigInt(observedHead); + + if (finalizedBlock === null) reasons.add('finality_unavailable'); + if (reorgDetected) reasons.add('reorg_detected'); + + const maxLag = this.readNonNegativeInt( + DISPUTE_READ_MODEL_MAX_LAG_CONFIG_KEY, + DEFAULT_MAX_LAG_BLOCKS, + ); + if (lagBlocks !== null && BigInt(lagBlocks) > BigInt(maxLag)) { + reasons.add('head_lag_exceeded'); + } + + const maxAgeMs = this.readNonNegativeInt( + DISPUTE_READ_MODEL_MAX_AGE_CONFIG_KEY, + 0, + ); + if (maxAgeMs > 0) { + if (lastSuccess === null) { + reasons.add('freshness_unavailable'); + } else if (Date.now() - Date.parse(lastSuccess) > maxAgeMs) { + reasons.add('freshness_expired'); + } + } + + if (snapshot && snapshot.status === 'unhealthy') { + reasons.add('indexer_unhealthy'); + } + + const staleReasons = [...reasons].sort(); + + return { + indexedBlock, + safeBlock, + finalizedBlock, + observedHead, + lagBlocks, + lastSuccess, + stale: staleReasons.length > 0, + staleReasons, + reorgDetected, + }; + } + + private async readCheckpoints( + chainId: number, + reasons: Set, + ): Promise { + try { + return await this.checkpointRepo.find({ where: { chainId } }); + } catch (err) { + this.logger.warn( + `Dispute read-model checkpoint read failed (chain ${chainId}): ${(err as Error)?.message ?? err}`, + ); + reasons.add('checkpoints_unavailable'); + return []; + } + } + + private aggregateCheckpoints(checkpoints: EventCheckpoint[]): { + safeBlock: string | null; + finalizedBlock: string | null; + lastSuccess: string | null; + } { + let safeBlock: bigint | null = null; + let finalizedBlock: bigint | null = null; + let lastSuccessMs = Number.NEGATIVE_INFINITY; + + for (const checkpoint of checkpoints) { + const safe = this.toBigInt(checkpoint.lastSafeBlock); + const finalized = this.toBigInt(checkpoint.lastFinalizedBlock); + if (safe !== null && (safeBlock === null || safe > safeBlock)) { + safeBlock = safe; + } + if ( + finalized !== null && + (finalizedBlock === null || finalized > finalizedBlock) + ) { + finalizedBlock = finalized; + } + if (checkpoint.updatedAt) { + const ms = new Date(checkpoint.updatedAt).getTime(); + if (!Number.isNaN(ms) && ms > lastSuccessMs) lastSuccessMs = ms; + } + } + + return { + safeBlock: safeBlock === null ? null : safeBlock.toString(), + finalizedBlock: + finalizedBlock === null ? null : finalizedBlock.toString(), + lastSuccess: + lastSuccessMs === Number.NEGATIVE_INFINITY + ? null + : new Date(lastSuccessMs).toISOString(), + }; + } + + private async readIndexedBlock( + chainId: number, + reasons: Set, + ): Promise { + try { + const raw = await this.disputeRepo + .createQueryBuilder('dispute') + .select('MAX(dispute.blockNumber)', 'maxBlock') + .where('dispute.chainId = :chainId', { chainId }) + .getRawOne<{ maxBlock: string | number | null }>(); + return this.toBigInt(raw?.maxBlock ?? null)?.toString() ?? null; + } catch (err) { + this.logger.warn( + `Dispute read-model indexed-block read failed (chain ${chainId}): ${(err as Error)?.message ?? err}`, + ); + reasons.add('projection_unavailable'); + return null; + } + } + + private async readIndexerSnapshot(): Promise + > | null> { + if (!this.blockchainState) { + return null; + } + try { + return await this.blockchainState.getIndexerHealth(); + } catch (err) { + this.logger.warn( + `Indexer health snapshot unavailable for dispute read models: ${(err as Error)?.message ?? err}`, + ); + return null; + } + } + + private computeLag( + observedHead: number | null, + indexedBlock: string | null, + ): string | null { + if (observedHead === null || indexedBlock === null) return null; + const distance = BigInt(observedHead) - BigInt(indexedBlock); + return (distance > 0n ? distance : 0n).toString(); + } + + private readNonNegativeInt(key: string, fallback: number): number { + const raw = this.configService.get(key); + if (raw === undefined || raw === null || raw === '') return fallback; + const value = Number(raw); + return Number.isInteger(value) && value >= 0 ? value : fallback; + } + + private toBigInt(value: unknown): bigint | null { + if (value === null || value === undefined) return null; + try { + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + return Number.isFinite(value) ? BigInt(Math.trunc(value)) : null; + } + if (typeof value !== 'string') return null; + const text = value.trim(); + return text === '' ? null : BigInt(text); + } catch { + return null; + } + } +} diff --git a/src/v2/disputes/read-models/dispute-read-model-provenance.service.ts b/src/v2/disputes/read-models/dispute-read-model-provenance.service.ts new file mode 100644 index 00000000..829a18a3 --- /dev/null +++ b/src/v2/disputes/read-models/dispute-read-model-provenance.service.ts @@ -0,0 +1,153 @@ +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + DisputeReadModelErrorBody, + ReleaseProvenance, +} from './dispute-read-model.types'; + +/** + * Config keys consulted, in priority order, for each provenance field. The + * `DISPUTE_READ_MODEL_*` names are the explicit override; the generic names let + * the read model reuse the release manifest values already supplied to the + * indexer. + */ +export const DISPUTE_READ_MODEL_PROVENANCE_CONFIG_KEYS = { + chainId: ['DISPUTE_READ_MODEL_CHAIN_ID', 'CHAIN_ID'], + releaseId: ['DISPUTE_READ_MODEL_RELEASE_ID', 'RELEASE_ID'], + deploymentBlock: [ + 'DISPUTE_READ_MODEL_DEPLOYMENT_BLOCK', + 'RELEASE_DEPLOYMENT_BLOCK', + 'DEPLOYMENT_BLOCK', + ], + artifactChecksum: [ + 'DISPUTE_READ_MODEL_ARTIFACT_CHECKSUM', + 'RELEASE_ARTIFACT_CHECKSUM', + 'ARTIFACT_CHECKSUM', + ], +} as const; + +const CHECKSUM_PATTERN = /^(0x)?[0-9a-fA-F]{64}$/; + +/** + * Resolves the release the dispute read models are bound to. + * + * Every chain-derived field published by the dispute read models must be + * attributable to one release (chainId + releaseId + deploymentBlock + + * artifactChecksum). If any of those cannot be resolved to a valid value the + * resolver fails closed: it never invents or defaults a release, because a + * wrong or missing release silently mismatches the frontend's pinned contract. + */ +@Injectable() +export class DisputeReadModelProvenanceService { + constructor(private readonly configService: ConfigService) {} + + resolve(): ReleaseProvenance { + const errors: string[] = []; + + const chainId = this.readInt( + DISPUTE_READ_MODEL_PROVENANCE_CONFIG_KEYS.chainId, + errors, + { min: 1 }, + ); + const releaseId = this.readString( + DISPUTE_READ_MODEL_PROVENANCE_CONFIG_KEYS.releaseId, + errors, + { maxLength: 128 }, + ); + const deploymentBlock = this.readInt( + DISPUTE_READ_MODEL_PROVENANCE_CONFIG_KEYS.deploymentBlock, + errors, + { min: 0 }, + ); + const artifactChecksum = this.readChecksum( + DISPUTE_READ_MODEL_PROVENANCE_CONFIG_KEYS.artifactChecksum, + errors, + ); + + if (errors.length > 0) { + const body: DisputeReadModelErrorBody = { + code: 'STALE_RECONCILIATION', + message: + 'Release provenance for the dispute read models is unavailable; refusing to serve unbound data', + failClosed: true, + reason: errors.join('; '), + provenance: null, + }; + throw new ServiceUnavailableException(body); + } + + return { + chainId: chainId as number, + releaseId: releaseId as string, + deploymentBlock: deploymentBlock as number, + artifactChecksum: artifactChecksum as string, + }; + } + + private readString( + keys: readonly string[], + errors: string[], + options: { maxLength: number; minLength?: number }, + ): string | null { + const raw = this.readRaw(keys); + if (raw === undefined || raw === null) { + errors.push(`${keys[0]}_missing`); + return null; + } + const value = String(raw).trim(); + if (value.length === 0) { + errors.push(`${keys[0]}_empty`); + return null; + } + if (value.length > options.maxLength) { + errors.push(`${keys[0]}_too_long`); + return null; + } + return value; + } + + private readInt( + keys: readonly string[], + errors: string[], + options: { min: number }, + ): number | null { + const raw = this.readRaw(keys); + if (raw === undefined || raw === null || raw === '') { + errors.push(`${keys[0]}_missing`); + return null; + } + const value = Number(raw); + if (!Number.isInteger(value) || value < options.min) { + errors.push(`${keys[0]}_invalid`); + return null; + } + return value; + } + + private readChecksum( + keys: readonly string[], + errors: string[], + ): string | null { + const raw = this.readRaw(keys); + if (raw === undefined || raw === null) { + errors.push(`${keys[0]}_missing`); + return null; + } + const value = String(raw).trim().toLowerCase(); + if (!CHECKSUM_PATTERN.test(value)) { + errors.push(`${keys[0]}_invalid`); + return null; + } + return value.startsWith('0x') ? value.slice(2) : value; + } + + private readRaw(keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = this.configService.get(key); + if (value !== undefined && value !== null && `${value}`.trim() !== '') { + return `${value}`; + } + } + return undefined; + } +} diff --git a/src/v2/disputes/read-models/dispute-read-model-view.ts b/src/v2/disputes/read-models/dispute-read-model-view.ts new file mode 100644 index 00000000..8a1d3d3b --- /dev/null +++ b/src/v2/disputes/read-models/dispute-read-model-view.ts @@ -0,0 +1,196 @@ +import { DataState } from '../../common/data-state.enum'; +import { DisputeStatus, ProjectDispute } from '../entities/project-dispute.entity'; +import { + DisputeReadModelItem, + DisputeReadModelPhase, + BondDisposition, + AppealAvailabilityReason, + ReleaseProvenance, + DisputeLastEvent, + DisputeDeadlines, + DisputeBond, + DisputeAppeal, + DisputeOutcome, + DisputeTimelineEvent, + DisputeSettlement, +} from './dispute-read-model.types'; + +export interface DisputeReadModelViewOptions { + provenance: ReleaseProvenance; + dataState: DataState; + /** Clock for deadline checks (injectable for tests). */ + now?: Date; + /** Optional max appeal round gate from contract config. */ + maxAppealRound?: number; + /** Optional provisional outcome from the challenged round. */ + provisionalOutcome?: string | null; + /** Optional bond disposition from terminal payload when status is resolved. */ + terminalBondDisposition?: BondDisposition | null; + /** Optional settlement data from reward projection. */ + settlement?: DisputeSettlement; +} + +function parseRoundIndex(roundId: string): number | null { + const m = String(roundId).match(/(\d+)\s*$/); + if (!m) return null; + return Number(m[1]); +} + +function toIso(value: Date | string | null | undefined): string | null { + if (value === null || value === undefined) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +/** + * Convert a ProjectDispute entity to a DisputeReadModelItem. + * + * This view function: + * - Separates provisional and final outcomes (never mixes them) + * - Derives phase from contract-derived fields only + * - Derives appeal availability from contract-derived fields only + * - Preserves exact integer strings for economic values + * - Fails closed on ambiguous bond disposition (returns 'unknown') + */ +export function toDisputeReadModelItem( + dispute: ProjectDispute, + options: DisputeReadModelViewOptions, +): DisputeReadModelItem { + const now = options.now ?? new Date(); + const isTerminal = + dispute.status === DisputeStatus.RESOLVED || + dispute.status === DisputeStatus.EXPIRED; + + // --- Appeal availability (contract-derived fields only) --- + let appealAvailable = false; + let appealReason: AppealAvailabilityReason = 'no_deadline'; + + if (isTerminal) { + appealAvailable = false; + appealReason = 'already_final'; + } else if ( + options.maxAppealRound != null && + parseRoundIndex(dispute.originalRoundId) != null && + (parseRoundIndex(dispute.originalRoundId) as number) >= options.maxAppealRound + ) { + appealAvailable = false; + appealReason = 'max_round'; + } else if (!dispute.deadline) { + appealAvailable = false; + appealReason = 'no_deadline'; + } else if (now.getTime() > new Date(dispute.deadline).getTime()) { + appealAvailable = false; + appealReason = 'deadline_passed'; + } else { + appealAvailable = true; + appealReason = 'window_open'; + } + + // --- Phase --- + let phase: DisputeReadModelPhase; + if (isTerminal) { + phase = 'finalized'; + } else if (appealAvailable) { + phase = 'appealable'; + } else { + phase = 'provisional'; + } + + // --- Bond disposition (exact; no invented amounts) --- + let disposition: BondDisposition = 'unknown'; + if (dispute.status === DisputeStatus.RAISED) { + disposition = 'locked'; + } else if (dispute.status === DisputeStatus.EXPIRED) { + disposition = 'refunded'; + } else if (dispute.status === DisputeStatus.RESOLVED) { + disposition = options.terminalBondDisposition ?? 'unknown'; + } + + // --- Timeline --- + const timeline: DisputeTimelineEvent[] = [ + { + ref: dispute.status === DisputeStatus.RAISED ? 'raise' : dispute.status === DisputeStatus.RESOLVED ? 'resolved' : 'expired', + txHash: dispute.eventTxHash, + logIndex: dispute.eventLogIndex, + blockNumber: String(dispute.blockNumber), + at: dispute.createdAt.toISOString(), + }, + ]; + + // --- Deadlines --- + const deadlines: DisputeDeadlines = { + dispute: toIso(dispute.deadline), + appeal: null, // TODO: derive from verification round appeal deadline + settledAt: toIso(dispute.status === DisputeStatus.RESOLVED || dispute.status === DisputeStatus.EXPIRED ? dispute.updatedAt : null), + }; + + // --- Bond --- + const bond: DisputeBond = { + amount: dispute.challengeBond, + asset: dispute.challengeBondAsset, + disposition, + }; + + // --- Appeal --- + const appeal: DisputeAppeal = { + available: appealAvailable, + appealRoundId: dispute.appealRoundId, + reason: appealReason, + }; + + // --- Outcome (phase-separated) --- + const outcome: DisputeOutcome = { + phase, + provisional: + phase === 'finalized' + ? null + : { + status: dispute.status, + outcome: options.provisionalOutcome ?? null, + deadline: toIso(dispute.deadline), + }, + final: + phase === 'finalized' + ? { + status: dispute.status === DisputeStatus.RESOLVED ? ('resolved' as const) : ('expired' as const), + outcome: dispute.status === DisputeStatus.RESOLVED ? dispute.resolvedOutcome : null, + settledAt: toIso(dispute.updatedAt), + } + : null, + }; + + // --- Settlement --- + const settlement: DisputeSettlement = options.settlement ?? { + pending: false, + state: null, + rewardAmount: null, + refundAmount: null, + slashedAmount: null, + }; + + // --- Last event --- + const lastEvent: DisputeLastEvent = { + blockNumber: String(dispute.blockNumber), + eventIndex: dispute.eventLogIndex, + txHash: dispute.eventTxHash, + at: dispute.updatedAt.toISOString(), + }; + + return { + disputeId: dispute.disputeId, + claimId: dispute.claimId, + originalRoundId: dispute.originalRoundId, + chainId: options.provenance.chainId, + status: dispute.status, + dataState: options.dataState, + phase, + lastEvent, + deadlines, + bond, + appeal, + outcome, + settlement, + timeline, + release: options.provenance, + }; +} diff --git a/src/v2/disputes/read-models/dispute-read-model.controller.ts b/src/v2/disputes/read-models/dispute-read-model.controller.ts new file mode 100644 index 00000000..05f23c89 --- /dev/null +++ b/src/v2/disputes/read-models/dispute-read-model.controller.ts @@ -0,0 +1,79 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from '../../../decorators/public.decorator'; +import { DisputeReadModelService } from './dispute-read-model.service'; +import { DisputeListQuery } from './dto/dispute-read-model-query.dto'; +import { + DisputeReadModelDetailResponseDto, + DisputeReadModelErrorDto, + DisputeReadModelListResponseDto, +} from './dto/dispute-read-model-response.dto'; + +/** + * Read-only, versioned dispute read models (PREWAVE-BE-007). + * + * Deliberately GET-only: every field is derived from canonical contract events + * via the dispute projection. The API relays a read model; it never settles + * protocol state and never accepts writes here. + */ +@ApiTags('V2 Dispute Read Models') +@Public() +@Controller('api/v2/dispute-read-models') +export class DisputeReadModelController { + constructor(private readonly readModels: DisputeReadModelService) {} + + @Get() + @ApiOperation({ + summary: 'Versioned, cursor-paginated dispute register read model', + description: + 'Returns canonical dispute read models ordered deterministically by disputeId. ' + + 'Every response binds the release provenance and freshness of the served ' + + 'snapshot and fails closed when that provenance or finality is unavailable.', + }) + @ApiResponse({ + status: 200, + description: 'Dispute register read model page', + type: DisputeReadModelListResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid cursor, filter, chain, or release', + type: DisputeReadModelErrorDto, + }) + @ApiResponse({ + status: 503, + description: 'Provenance or finality unavailable; failing closed', + type: DisputeReadModelErrorDto, + }) + async getList(@Query() query: DisputeListQuery) { + return this.readModels.getList(query); + } + + @Get(':disputeId') + @ApiOperation({ + summary: 'Versioned canonical dispute detail read model', + description: + 'Returns the canonical read model for a single dispute, including its ' + + 'phase-separated outcome, deadlines, bond disposition, appeal availability, ' + + 'and release binding.', + }) + @ApiParam({ name: 'disputeId', description: 'Canonical dispute id (format: claimId:roundId)' }) + @ApiResponse({ + status: 200, + description: 'Dispute detail read model', + type: DisputeReadModelDetailResponseDto, + }) + @ApiResponse({ + status: 404, + description: 'Dispute is not projected', + type: DisputeReadModelErrorDto, + }) + @ApiResponse({ + status: 503, + description: 'Provenance or finality unavailable; failing closed', + type: DisputeReadModelErrorDto, + }) + async getDetail(@Param('disputeId') disputeId: string) { + return this.readModels.getDetail(disputeId); + } +} diff --git a/src/v2/disputes/read-models/dispute-read-model.cursor.ts b/src/v2/disputes/read-models/dispute-read-model.cursor.ts new file mode 100644 index 00000000..8a5f19ee --- /dev/null +++ b/src/v2/disputes/read-models/dispute-read-model.cursor.ts @@ -0,0 +1,63 @@ +import { Buffer } from 'buffer'; + +export interface DisputeListCursor { + v: 1; + disputeId: string; + snapshotAt: string; + order: string; + chainId: number; + status: string | null; +} + +export function encodeDisputeListCursor(cursor: DisputeListCursor): string { + const json = JSON.stringify(cursor); + return Buffer.from(json, 'utf-8').toString('base64url'); +} + +export function decodeDisputeListCursor(encoded: string): DisputeListCursor { + try { + const json = Buffer.from(encoded, 'base64url').toString('utf-8'); + const parsed = JSON.parse(json) as unknown; + + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Invalid cursor: not an object'); + } + + const cursor = parsed as Record; + + if (cursor.v !== 1) { + throw new Error(`Invalid cursor version: ${cursor.v}`); + } + + if (typeof cursor.disputeId !== 'string') { + throw new Error('Invalid cursor: disputeId must be a string'); + } + + if (typeof cursor.snapshotAt !== 'string') { + throw new Error('Invalid cursor: snapshotAt must be a string'); + } + + if (typeof cursor.order !== 'string') { + throw new Error('Invalid cursor: order must be a string'); + } + + if (typeof cursor.chainId !== 'number') { + throw new Error('Invalid cursor: chainId must be a number'); + } + + if (cursor.status !== null && typeof cursor.status !== 'string') { + throw new Error('Invalid cursor: status must be a string or null'); + } + + return { + v: 1, + disputeId: cursor.disputeId, + snapshotAt: cursor.snapshotAt, + order: cursor.order, + chainId: cursor.chainId, + status: cursor.status ?? null, + }; + } catch (err) { + throw new Error(`Failed to decode cursor: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/src/v2/disputes/read-models/dispute-read-model.service.spec.ts b/src/v2/disputes/read-models/dispute-read-model.service.spec.ts new file mode 100644 index 00000000..cc353811 --- /dev/null +++ b/src/v2/disputes/read-models/dispute-read-model.service.spec.ts @@ -0,0 +1,478 @@ +import { HttpException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FindOperator, Repository } from 'typeorm'; +import { FinalityPolicyService } from '../../../config/finality-policy.service'; +import { DataState } from '../../common/data-state.enum'; +import { DisputeStatus, ProjectDispute } from '../entities/project-dispute.entity'; +import { DisputeReadModelService } from './dispute-read-model.service'; +import { DisputeReadModelProvenanceService } from './dispute-read-model-provenance.service'; +import { DisputeReadModelFreshnessService } from './dispute-read-model-freshness.service'; +import { + ReleaseProvenance, + ReadModelFreshness, +} from './dispute-read-model.types'; + +const CHAIN_ID = 11155420; +const RELEASE_ID = 'rel-opsepolia-20261015-v2'; +const SNAPSHOT_PAST = new Date('2026-01-01T00:00:00.000Z'); +const CONCURRENT_FUTURE = new Date('2030-01-01T00:00:00.000Z'); + +const PROVENANCE: ReleaseProvenance = { + chainId: CHAIN_ID, + releaseId: RELEASE_ID, + deploymentBlock: 24850000, + artifactChecksum: 'f'.repeat(64), +}; + +function healthyFreshness( + overrides: Partial = {}, +): ReadModelFreshness { + return { + indexedBlock: '24859990', + safeBlock: '24860010', + finalizedBlock: '24860000', + observedHead: 24860012, + lagBlocks: '22', + lastSuccess: '2026-10-16T00:00:00.000Z', + stale: false, + staleReasons: [], + reorgDetected: false, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Faithful in-memory repository fake. +// --------------------------------------------------------------------------- + +function matches(value: unknown, condition: unknown): boolean { + if (condition instanceof FindOperator) { + const target = condition.value; + switch (condition.type) { + case 'moreThan': + return (value as string) > (target as string); + case 'lessThanOrEqual': { + const left = + value instanceof Date ? value.getTime() : (value as number); + const right = + target instanceof Date ? target.getTime() : (target as number); + return left <= right; + } + case 'in': + return (target as unknown[]).includes(value); + default: + throw new Error(`unsupported operator: ${String(condition.type)}`); + } + } + return value === condition; +} + +class FakeRepo> { + rows: T[] = []; + + async save(entity: Partial): Promise { + const row = { ...entity } as T; + this.rows.push(row); + return row; + } + + async find(options?: { + where?: Record; + order?: Record; + take?: number; + }): Promise { + let result = this.rows.filter((row) => + Object.entries(options?.where ?? {}).every(([key, condition]) => + matches(row[key], condition), + ), + ); + for (const [key, direction] of Object.entries(options?.order ?? {})) { + result = [...result].sort((a, b) => { + const left = a[key]; + const right = b[key]; + if (left === right) return 0; + const cmp = left > right ? 1 : -1; + return direction === 'ASC' ? cmp : -cmp; + }); + } + if (options?.take) { + result = result.slice(0, options.take); + } + return result; + } + + async findOne(options?: { where?: Record }): Promise { + const results = await this.find(options); + return results[0] ?? null; + } + + async createQueryBuilder() { + return { + select: () => this, + where: () => this, + getRawOne: async () => { + const maxBlock = this.rows.length > 0 + ? Math.max(...this.rows.map((r) => Number(r.blockNumber))) + : null; + return { maxBlock: maxBlock?.toString() ?? null }; + }, + } as any; + } +} + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +class FakeProvenanceService { + resolve() { + return PROVENANCE; + } +} + +class FakeFreshnessService { + async snapshot() { + return healthyFreshness(); + } +} + +class FakeFinalityPolicyService { + classifyByCheckpoint(_blockNumber: string, _checkpoint: any) { + return DataState.FINALIZED; + } +} + +describe('DisputeReadModelService', () => { + let service: DisputeReadModelService; + let disputeRepo: FakeRepo; + let roundRepo: FakeRepo; + let provenanceService: FakeProvenanceService; + let freshnessService: FakeFreshnessService; + let finalityPolicy: FakeFinalityPolicyService; + + beforeEach(() => { + disputeRepo = new FakeRepo(); + roundRepo = new FakeRepo(); + provenanceService = new FakeProvenanceService(); + freshnessService = new FakeFreshnessService(); + finalityPolicy = new FakeFinalityPolicyService(); + + service = new DisputeReadModelService( + disputeRepo as unknown as Repository, + roundRepo as unknown as Repository, + provenanceService as unknown as DisputeReadModelProvenanceService, + freshnessService as unknown as DisputeReadModelFreshnessService, + finalityPolicy as unknown as FinalityPolicyService, + ); + }); + + describe('getList', () => { + it('returns empty register when no disputes exist', async () => { + const result = await service.getList({}); + expect(result.data).toEqual([]); + expect(result.readModel).toBe('dispute-register'); + expect(result.readModelVersion).toBe('dispute-read-model/v1'); + expect(result.pagination.hasMore).toBe(false); + }); + + it('returns disputes in deterministic order', async () => { + disputeRepo.rows = [ + { + disputeId: 'claim-0002:round-1', + claimId: 'claim-0002', + originalRoundId: 'round-1', + status: DisputeStatus.RAISED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'a'.repeat(40), + deadline: new Date('2026-10-20T00:00:00.000Z'), + eventTxHash: '0x' + '1'.repeat(64), + eventLogIndex: 0, + blockNumber: '24850010', + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T00:00:00.000Z'), + dataState: DataState.OBSERVED, + appealRoundId: null, + resolvedOutcome: null, + } as ProjectDispute, + { + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + status: DisputeStatus.RAISED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'b'.repeat(40), + deadline: new Date('2026-10-20T00:00:00.000Z'), + eventTxHash: '0x' + '2'.repeat(64), + eventLogIndex: 0, + blockNumber: '24850005', + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T00:00:00.000Z'), + dataState: DataState.OBSERVED, + appealRoundId: null, + resolvedOutcome: null, + } as ProjectDispute, + ]; + + const result = await service.getList({}); + expect(result.data.length).toBe(2); + expect(result.data[0].disputeId).toBe('claim-0001:round-1'); + expect(result.data[1].disputeId).toBe('claim-0002:round-1'); + }); + + it('paginates with cursor', async () => { + for (let i = 1; i <= 25; i++) { + disputeRepo.rows.push({ + disputeId: `claim-${String(i).padStart(4, '0')}:round-1`, + claimId: `claim-${String(i).padStart(4, '0')}`, + originalRoundId: 'round-1', + status: DisputeStatus.RAISED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'a'.repeat(40), + deadline: new Date('2026-10-20T00:00:00.000Z'), + eventTxHash: '0x' + String(i).repeat(64), + eventLogIndex: 0, + blockNumber: String(24850000 + i), + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T00:00:00.000Z'), + dataState: DataState.OBSERVED, + appealRoundId: null, + resolvedOutcome: null, + } as ProjectDispute); + } + + const page1 = await service.getList({ limit: 20 }); + expect(page1.data.length).toBe(20); + expect(page1.pagination.hasMore).toBe(true); + expect(page1.pagination.nextCursor).toBeTruthy(); + + const page2 = await service.getList({ + limit: 20, + cursor: page1.pagination.nextCursor ?? undefined, + }); + expect(page2.data.length).toBe(5); + expect(page2.pagination.hasMore).toBe(false); + }); + + it('filters by status', async () => { + disputeRepo.rows = [ + { + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + status: DisputeStatus.RAISED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'a'.repeat(40), + deadline: new Date('2026-10-20T00:00:00.000Z'), + eventTxHash: '0x' + '1'.repeat(64), + eventLogIndex: 0, + blockNumber: '24850010', + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T00:00:00.000Z'), + dataState: DataState.OBSERVED, + appealRoundId: null, + resolvedOutcome: null, + } as ProjectDispute, + { + disputeId: 'claim-0002:round-1', + claimId: 'claim-0002', + originalRoundId: 'round-1', + status: DisputeStatus.RESOLVED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'b'.repeat(40), + deadline: new Date('2026-10-20T00:00:00.000Z'), + eventTxHash: '0x' + '2'.repeat(64), + eventLogIndex: 0, + blockNumber: '24850020', + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T01:00:00.000Z'), + dataState: DataState.FINALIZED, + appealRoundId: null, + resolvedOutcome: 'overturned', + } as ProjectDispute, + ]; + + const result = await service.getList({ status: 'raised' }); + expect(result.data.length).toBe(1); + expect(result.data[0].status).toBe('raised'); + }); + + it('fails closed when freshness is stale', async () => { + (freshnessService as any).snapshot = async () => + healthyFreshness({ stale: true, staleReasons: ['finality_unavailable'] }); + + await expect(service.getList({})).rejects.toThrow(HttpException); + const error = await service.getList({}).catch((e) => e); + expect(error.response.code).toBe('STALE_RECONCILIATION'); + expect(error.response.failClosed).toBe(true); + }); + + it('fails closed when requested chain does not match served release', async () => { + await expect(service.getList({ chainId: 1 })).rejects.toThrow(HttpException); + const error = await service.getList({ chainId: 1 }).catch((e) => e); + expect(error.response.code).toBe('UNSUPPORTED_CHAIN'); + expect(error.response.failClosed).toBe(true); + }); + + it('fails closed when requested release does not match served release', async () => { + await expect(service.getList({ releaseId: 'wrong-release' })).rejects.toThrow( + HttpException, + ); + const error = await service.getList({ releaseId: 'wrong-release' }).catch((e) => e); + expect(error.response.code).toBe('INVALID_ARTIFACT_VERSION'); + expect(error.response.failClosed).toBe(true); + }); + }); + + describe('getDetail', () => { + it('returns dispute detail when it exists', async () => { + disputeRepo.rows = [ + { + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + status: DisputeStatus.RAISED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'a'.repeat(40), + deadline: new Date('2026-10-20T00:00:00.000Z'), + eventTxHash: '0x' + '1'.repeat(64), + eventLogIndex: 0, + blockNumber: '24850010', + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T00:00:00.000Z'), + dataState: DataState.OBSERVED, + appealRoundId: null, + resolvedOutcome: null, + } as ProjectDispute, + ]; + + const result = await service.getDetail('claim-0001:round-1'); + expect(result.data.disputeId).toBe('claim-0001:round-1'); + expect(result.readModel).toBe('dispute-detail'); + expect(result.readModelVersion).toBe('dispute-read-model/v1'); + }); + + it('fails closed when dispute does not exist', async () => { + await expect(service.getDetail('unknown:round-1')).rejects.toThrow(HttpException); + const error = await service.getDetail('unknown:round-1').catch((e) => e); + expect(error.response.code).toBe('NOT_FOUND'); + expect(error.response.failClosed).toBe(true); + }); + + it('fails closed when freshness is stale', async () => { + disputeRepo.rows = [ + { + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + status: DisputeStatus.RAISED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'a'.repeat(40), + deadline: new Date('2026-10-20T00:00:00.000Z'), + eventTxHash: '0x' + '1'.repeat(64), + eventLogIndex: 0, + blockNumber: '24850010', + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T00:00:00.000Z'), + dataState: DataState.OBSERVED, + appealRoundId: null, + resolvedOutcome: null, + } as ProjectDispute, + ]; + + (freshnessService as any).snapshot = async () => + healthyFreshness({ stale: true, staleReasons: ['finality_unavailable'] }); + + await expect(service.getDetail('claim-0001:round-1')).rejects.toThrow(HttpException); + const error = await service.getDetail('claim-0001:round-1').catch((e) => e); + expect(error.response.code).toBe('STALE_RECONCILIATION'); + expect(error.response.failClosed).toBe(true); + }); + }); + + describe('phase separation', () => { + it('provisional disputes have provisional outcome only', async () => { + disputeRepo.rows = [ + { + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + status: DisputeStatus.RAISED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'a'.repeat(40), + deadline: new Date('2026-10-15T00:00:00.000Z'), // Past deadline + eventTxHash: '0x' + '1'.repeat(64), + eventLogIndex: 0, + blockNumber: '24850010', + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T00:00:00.000Z'), + dataState: DataState.OBSERVED, + appealRoundId: null, + resolvedOutcome: null, + } as ProjectDispute, + ]; + + const result = await service.getDetail('claim-0001:round-1'); + expect(result.data.phase).toBe('provisional'); + expect(result.data.outcome.phase).toBe('provisional'); + expect(result.data.outcome.provisional).not.toBeNull(); + expect(result.data.outcome.final).toBeNull(); + }); + + it('finalized disputes have final outcome only', async () => { + disputeRepo.rows = [ + { + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + status: DisputeStatus.RESOLVED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'a'.repeat(40), + deadline: new Date('2026-10-20T00:00:00.000Z'), + eventTxHash: '0x' + '1'.repeat(64), + eventLogIndex: 0, + blockNumber: '24850010', + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T01:00:00.000Z'), + dataState: DataState.FINALIZED, + appealRoundId: null, + resolvedOutcome: 'overturned', + } as ProjectDispute, + ]; + + const result = await service.getDetail('claim-0001:round-1'); + expect(result.data.phase).toBe('finalized'); + expect(result.data.outcome.phase).toBe('finalized'); + expect(result.data.outcome.provisional).toBeNull(); + expect(result.data.outcome.final).not.toBeNull(); + expect(result.data.outcome.final?.status).toBe('resolved'); + }); + }); + + describe('economic values preserve exact integer units', () => { + it('bond amount is preserved as exact integer string', async () => { + disputeRepo.rows = [ + { + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + status: DisputeStatus.RAISED, + challengeBond: '1000000000000000000', + challengeBondAsset: '0x' + 'a'.repeat(40), + deadline: new Date('2026-10-20T00:00:00.000Z'), + eventTxHash: '0x' + '1'.repeat(64), + eventLogIndex: 0, + blockNumber: '24850010', + createdAt: new Date('2026-10-15T00:00:00.000Z'), + updatedAt: new Date('2026-10-15T00:00:00.000Z'), + dataState: DataState.OBSERVED, + appealRoundId: null, + resolvedOutcome: null, + } as ProjectDispute, + ]; + + const result = await service.getDetail('claim-0001:round-1'); + expect(result.data.bond.amount).toBe('1000000000000000000'); + expect(typeof result.data.bond.amount).toBe('string'); + }); + }); +}); diff --git a/src/v2/disputes/read-models/dispute-read-model.service.ts b/src/v2/disputes/read-models/dispute-read-model.service.ts new file mode 100644 index 00000000..2f50f5c8 --- /dev/null +++ b/src/v2/disputes/read-models/dispute-read-model.service.ts @@ -0,0 +1,371 @@ +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + FindOptionsWhere, + In, + LessThanOrEqual, + MoreThan, + Repository, +} from 'typeorm'; +import { ProjectDispute } from '../entities/project-dispute.entity'; +import { EventCheckpoint } from '../../events/entities/event-checkpoint.entity'; +import { ProjectVerificationRound } from '../../verification/entities/project-verification-round.entity'; +import { FinalityPolicyService } from '../../../config/finality-policy.service'; +import { + DISPUTE_DETAIL_READ_MODEL, + DISPUTE_REGISTER_READ_MODEL, + DISPUTE_READ_MODEL_DEFAULT_LIMIT, + DISPUTE_READ_MODEL_MAX_LIMIT, + DISPUTE_READ_MODEL_ORDER, + DISPUTE_READ_MODEL_VERSION, + DisputeDeadlines, + DisputeReadModelDetailResponse, + DisputeReadModelErrorBody, + DisputeReadModelItem, + DisputeReadModelListResponse, + DisputeSettlement, + ReadModelFreshness, + ReleaseProvenance, +} from './dispute-read-model.types'; +import { + DisputeListCursor, + decodeDisputeListCursor, + encodeDisputeListCursor, +} from './dispute-read-model.cursor'; +import { DisputeReadModelProvenanceService } from './dispute-read-model-provenance.service'; +import { DisputeReadModelFreshnessService } from './dispute-read-model-freshness.service'; +import { toDisputeReadModelItem } from './dispute-read-model-view'; +import { DisputeListQuery } from './dto/dispute-read-model-query.dto'; + +function toIso(value: Date | string | null | undefined): string | null { + if (value === null || value === undefined) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +/** + * PREWAVE-BE-007 — publishes the versioned dispute register and dispute detail read + * models. + * + * Authoritative inputs are chain-derived only: the canonical dispute lifecycle + * projection (`v2_project_dispute`), the verification rounds, and the + * finality checkpoints. The service never mutates protocol state and never + * invents fields. + * + * Fail-closed gates (each throws a stable, machine-readable envelope): + * - missing/invalid release provenance -> 503 STALE_RECONCILIATION + * - requested chain/release != served release -> 400 UNSUPPORTED_CHAIN / + * INVALID_ARTIFACT_VERSION + * - missing finality / stale / reorg detected -> 503 STALE_RECONCILIATION + * - dispute not projected -> 404 NOT_FOUND + * - cursor/filter mismatch between pages -> 400 (Bad Request) + */ +@Injectable() +export class DisputeReadModelService { + private readonly logger = new Logger(DisputeReadModelService.name); + + constructor( + @InjectRepository(ProjectDispute) + private readonly disputeRepo: Repository, + @InjectRepository(ProjectVerificationRound) + private readonly roundRepo: Repository, + private readonly provenanceService: DisputeReadModelProvenanceService, + private readonly freshnessService: DisputeReadModelFreshnessService, + private readonly finalityPolicy: FinalityPolicyService, + ) {} + + async getList(query: DisputeListQuery): Promise { + const provenance = this.provenanceService.resolve(); + this.assertRequestedRelease(query, provenance); + + const freshness = await this.freshnessService.snapshot(provenance.chainId); + this.assertFresh(freshness, provenance); + + const limit = this.clampLimit(query.limit); + const cursor = this.resolveCursor(query, provenance); + const snapshotAt = cursor?.snapshotAt ?? new Date().toISOString(); + + const where: FindOptionsWhere = { + createdAt: LessThanOrEqual(new Date(snapshotAt)), + }; + if (query.status) { + where.status = query.status as ProjectDispute['status']; + } + if (cursor) { + where.disputeId = MoreThan(cursor.disputeId); + } + + const rows = await this.disputeRepo.find({ + where, + order: { disputeId: 'ASC' }, + take: limit + 1, + }); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + + const data = await this.hydrate(page, provenance, freshness); + + const lastRow = page[page.length - 1]; + const nextCursor = + hasMore && lastRow + ? encodeDisputeListCursor({ + v: 1, + disputeId: lastRow.disputeId, + snapshotAt, + order: DISPUTE_READ_MODEL_ORDER, + chainId: provenance.chainId, + status: query.status ?? null, + }) + : null; + + return { + readModel: DISPUTE_REGISTER_READ_MODEL, + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: new Date().toISOString(), + provenance, + freshness, + pagination: { + nextCursor, + hasMore, + limit, + order: DISPUTE_READ_MODEL_ORDER, + snapshotAt, + }, + data, + }; + } + + async getDetail(disputeId: string): Promise { + const provenance = this.provenanceService.resolve(); + const freshness = await this.freshnessService.snapshot(provenance.chainId); + this.assertFresh(freshness, provenance); + + const row = await this.disputeRepo.findOne({ + where: { disputeId }, + }); + + if (!row) { + throw this.failClosed( + 'NOT_FOUND', + HttpStatus.NOT_FOUND, + `No canonical dispute read model is published for dispute ${disputeId}`, + 'dispute_not_projected', + provenance, + ); + } + + const [item] = await this.hydrate([row], provenance, freshness); + + return { + readModel: DISPUTE_DETAIL_READ_MODEL, + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: new Date().toISOString(), + provenance, + freshness, + data: item, + }; + } + + // ------------------------------------------------------------------------- + // Gates + // ------------------------------------------------------------------------- + + private assertRequestedRelease( + query: DisputeListQuery, + provenance: ReleaseProvenance, + ): void { + if (query.chainId !== undefined && query.chainId !== provenance.chainId) { + throw this.failClosed( + 'UNSUPPORTED_CHAIN', + HttpStatus.BAD_REQUEST, + 'Requested chain is not the release the dispute read models are bound to', + 'chain_release_mismatch', + provenance, + { chainId: provenance.chainId }, + { chainId: query.chainId }, + ); + } + if ( + query.releaseId !== undefined && + query.releaseId !== provenance.releaseId + ) { + throw this.failClosed( + 'INVALID_ARTIFACT_VERSION', + HttpStatus.BAD_REQUEST, + 'Requested release is not the release the dispute read models are bound to', + 'wrong_release', + provenance, + { releaseId: provenance.releaseId }, + { releaseId: query.releaseId }, + ); + } + } + + private assertFresh( + freshness: ReadModelFreshness, + provenance: ReleaseProvenance, + ): void { + if (!freshness.stale) return; + this.logger.warn( + `Dispute read models fail closed (release=${provenance.releaseId}): ${freshness.staleReasons.join(', ')}`, + ); + throw this.failClosed( + 'STALE_RECONCILIATION', + HttpStatus.SERVICE_UNAVAILABLE, + 'Dispute read models are not fresh enough to serve; failing closed', + freshness.staleReasons.join(', '), + provenance, + ); + } + + private resolveCursor( + query: DisputeListQuery, + provenance: ReleaseProvenance, + ): DisputeListCursor | null { + if (!query.cursor) return null; + const cursor = decodeDisputeListCursor(query.cursor); + + if (cursor.chainId !== provenance.chainId) { + throw this.failClosed( + 'INVALID_ARTIFACT_VERSION', + HttpStatus.BAD_REQUEST, + 'Cursor belongs to a different release than the one being served', + 'cursor_release_mismatch', + provenance, + { chainId: provenance.chainId }, + { chainId: cursor.chainId }, + ); + } + if (cursor.status !== (query.status ?? null)) { + throw new HttpException( + 'Cursor filter mismatch: status must match the cursor', + HttpStatus.BAD_REQUEST, + ); + } + return cursor; + } + + private clampLimit(limit: number | undefined): number { + if (!limit || !Number.isFinite(limit) || limit <= 0) { + return DISPUTE_READ_MODEL_DEFAULT_LIMIT; + } + return Math.min(Math.trunc(limit), DISPUTE_READ_MODEL_MAX_LIMIT); + } + + // ------------------------------------------------------------------------- + // Hydration (batched, no per-row N+1) + // ------------------------------------------------------------------------- + + private async hydrate( + rows: ProjectDispute[], + provenance: ReleaseProvenance, + freshness: ReadModelFreshness, + ): Promise { + if (rows.length === 0) return []; + + const disputeIds = rows.map((row) => row.disputeId); + const claimIds = rows.map((row) => row.claimId); + const finalizedBlock = + freshness.finalizedBlock === null + ? null + : BigInt(freshness.finalizedBlock); + const checkpoint = { + lastSafeBlock: freshness.safeBlock ?? '0', + lastFinalizedBlock: freshness.finalizedBlock ?? '0', + }; + + const rounds = await this.roundRepo.find({ + where: { claimId: In(claimIds) }, + }); + + const latestRoundByClaim = new Map(); + for (const round of rounds) { + const current = latestRoundByClaim.get(round.claimId); + if (!current || this.blockOf(round) > this.blockOf(current)) { + latestRoundByClaim.set(round.claimId, round); + } + } + + return rows.map((row) => { + const round = latestRoundByClaim.get(row.claimId); + const dataState = this.finalityPolicy.classifyByCheckpoint( + row.blockNumber, + checkpoint, + ); + + const deadlines: DisputeDeadlines = { + dispute: toIso(row.deadline), + appeal: toIso(round?.appealDeadline), + settledAt: + row.status === 'resolved' || row.status === 'expired' + ? toIso(row.updatedAt) + : null, + }; + + // TODO: Integrate with settlement projection when available + const settlement: DisputeSettlement = { + pending: false, + state: null, + rewardAmount: null, + refundAmount: null, + slashedAmount: null, + }; + + return toDisputeReadModelItem(row, { + provenance, + dataState, + provisionalOutcome: null, // TODO: derive from round outcome + terminalBondDisposition: row.status === 'resolved' ? 'unknown' : null, + settlement, + }); + }); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private failClosed( + code: DisputeReadModelErrorBody['code'], + status: number, + message: string, + reason: string, + provenance: ReleaseProvenance | null, + expected?: Record, + received?: Record, + ): HttpException { + if (code !== 'NOT_FOUND') { + this.logger.warn(`Dispute read models fail closed: ${code}/${reason}`); + } + const body: DisputeReadModelErrorBody = { + code, + message, + failClosed: true, + reason, + provenance, + ...(expected ? { expected } : {}), + ...(received ? { received } : {}), + }; + return new HttpException(body, status); + } + + private blockOf(round: ProjectVerificationRound): bigint { + return this.safeBigInt(round.openedAtBlock) ?? 0n; + } + + private safeBigInt(value: unknown): bigint | null { + if (value === null || value === undefined) return null; + try { + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + return Number.isFinite(value) ? BigInt(Math.trunc(value)) : null; + } + if (typeof value !== 'string') return null; + const text = value.trim(); + return text === '' ? null : BigInt(text); + } catch { + return null; + } + } +} diff --git a/src/v2/disputes/read-models/dispute-read-model.types.ts b/src/v2/disputes/read-models/dispute-read-model.types.ts new file mode 100644 index 00000000..aff6238f --- /dev/null +++ b/src/v2/disputes/read-models/dispute-read-model.types.ts @@ -0,0 +1,287 @@ +import { DataState } from '../../common/data-state.enum'; +import { DisputeStatus } from '../entities/project-dispute.entity'; + +/** + * PREWAVE-BE-007 — Versioned Dispute, Appeal, and Settlement Read Models. + * + * These types define the *published* contract for the versioned dispute + * read-model endpoints. They are the single source of truth the frontend + * consumes, so field-name and phase inference are never required: + * + * - every response carries its `readModel`/`readModelVersion`, + * - every item carries explicit `phase` + `dataState` and a + * phase-separated `outcome` (provisional is never serialized as final), + * - every chain-derived field is bound to the consumed release + * (chainId, releaseId, deploymentBlock, artifactChecksum), + * - provenance and freshness accompany every response. + * + * Authoritative inputs are chain-derived only: the canonical dispute projection + * (`v2_project_dispute`), the verification rounds, and the reward/settlement + * projections. The API never mutates protocol state and never invents fields. + */ + +/** Version tag of the published read-model contract. Bump on any breaking change. */ +export const DISPUTE_READ_MODEL_VERSION = 'dispute-read-model/v1'; + +export const DISPUTE_REGISTER_READ_MODEL = 'dispute-register'; +export const DISPUTE_DETAIL_READ_MODEL = 'dispute-detail'; + +/** Deterministic ordering for the dispute register (immutable, keyset-stable). */ +export const DISPUTE_READ_MODEL_ORDER = 'disputeId:asc'; + +export const DISPUTE_READ_MODEL_DEFAULT_LIMIT = 20; +export const DISPUTE_READ_MODEL_MAX_LIMIT = 100; + +/** Lifecycle states the canonical dispute projection can hold. */ +export const DISPUTE_READ_MODEL_STATES = Object.values(DisputeStatus); + +/** Terminal lifecycle states — a dispute in one of these has no further transition. */ +export const TERMINAL_DISPUTE_STATES: readonly string[] = [ + DisputeStatus.RESOLVED, + DisputeStatus.EXPIRED, +]; + +export type DisputeReadModelPhase = 'provisional' | 'appealable' | 'finalized'; +export const DISPUTE_READ_MODEL_PHASES: readonly DisputeReadModelPhase[] = [ + 'provisional', + 'appealable', + 'finalized', +]; + +export type BondDisposition = 'locked' | 'refunded' | 'slashed' | 'unknown'; + +export type AppealAvailabilityReason = + | 'window_open' + | 'deadline_passed' + | 'already_final' + | 'max_round' + | 'no_deadline'; + +export const APPEAL_AVAILABILITY_REASONS: readonly AppealAvailabilityReason[] = [ + 'window_open', + 'deadline_passed', + 'already_final', + 'max_round', + 'no_deadline', +]; + +/** Canonical error codes for fail-closed read-model responses. */ +export type DisputeReadModelErrorErrorCode = + | 'STALE_RECONCILIATION' + | 'INVALID_ARTIFACT_VERSION' + | 'UNSUPPORTED_CHAIN' + | 'NOT_FOUND'; + +/** + * Emitted keys must stay in sync with {@link DISPUTE_READ_MODEL_ITEM_KEYS} and + * the response key sets below; the drift test asserts this. + */ +export interface ReleaseProvenance { + chainId: number; + releaseId: string; + deploymentBlock: number; + artifactChecksum: string; +} + +export interface ReadModelFreshness { + /** Highest projected dispute block, or null when nothing is projected. */ + indexedBlock: string | null; + safeBlock: string | null; + finalizedBlock: string | null; + /** Highest block observed from the RPC provider, or null when unknown. */ + observedHead: number | null; + /** observedHead - indexedBlock (>= 0), or null when either side is unknown. */ + lagBlocks: string | null; + /** ISO timestamp of the last projection checkpoint advance, or null. */ + lastSuccess: string | null; + /** True when any {@link staleReasons} is present — callers must fail closed. */ + stale: boolean; + /** Machine-readable staleness/ambiguity reasons, sorted for determinism. */ + staleReasons: string[]; + /** True when the projection is ahead of the observed chain (reorg suspected). */ + reorgDetected: boolean; +} + +export interface DisputeLastEvent { + blockNumber: string; + eventIndex: number; + txHash: string; + at: string; +} + +export interface DisputeDeadlines { + /** Canonical dispute deadline (when challenge bond must be posted), or null. */ + dispute: string | null; + /** Canonical appeal deadline, or null when none is projected. */ + appeal: string | null; + /** When the dispute reached a terminal state, or null. */ + settledAt: string | null; +} + +export interface DisputeBond { + /** Exact integer string amount (never a float/number). */ + amount: string | null; + /** Asset address (e.g., token contract). */ + asset: string | null; + /** Disposition of the bond (contract-derived). */ + disposition: BondDisposition; +} + +export interface DisputeAppeal { + /** Whether an appeal is currently available (contract-derived). */ + available: boolean; + /** Appeal round ID when available, or null. */ + appealRoundId: string | null; + /** Machine-readable reason for availability decision. */ + reason: AppealAvailabilityReason; +} + +/** + * Phase-separated outcome. Exactly one of `provisional` / `final` is non-null: + * a provisional outcome is never copied into `final`, so a frontend can render + * "provisional" data without ever presenting it as final. + */ +export interface DisputeOutcome { + phase: DisputeReadModelPhase; + provisional: { + status: string; + outcome: string | null; + deadline: string | null; + } | null; + final: { + status: 'resolved' | 'expired'; + outcome: string | null; + settledAt: string | null; + } | null; +} + +export interface DisputeTimelineEvent { + ref: 'raise' | 'resolved' | 'expired'; + txHash: string; + logIndex: number; + blockNumber: string; + at: string; +} + +export interface DisputeSettlement { + /** Whether settlement is pending for this dispute. */ + pending: boolean; + /** Settlement state when available (contract-derived). */ + state: string | null; + /** Exact reward amount string if applicable, or null. */ + rewardAmount: string | null; + /** Exact refund amount string if applicable, or null. */ + refundAmount: string | null; + /** Exact slashed amount string if applicable, or null. */ + slashedAmount: string | null; +} + +export interface DisputeReadModelItem { + disputeId: string; + claimId: string; + originalRoundId: string; + chainId: number; + /** Canonical projected lifecycle state. */ + status: string; + /** Chain finality of the last applied event (observed/safe/finalized). */ + dataState: DataState; + phase: DisputeReadModelPhase; + lastEvent: DisputeLastEvent; + deadlines: DisputeDeadlines; + bond: DisputeBond; + appeal: DisputeAppeal; + outcome: DisputeOutcome; + settlement: DisputeSettlement; + timeline: DisputeTimelineEvent[]; + /** Per-item release binding so a row can never be read out of release. */ + release: ReleaseProvenance; +} + +export interface DisputeListPagination { + nextCursor: string | null; + hasMore: boolean; + limit: number; + order: string; + /** ISO snapshot pinned for the whole pagination walk. */ + snapshotAt: string; +} + +export interface DisputeReadModelListResponse { + readModel: typeof DISPUTE_REGISTER_READ_MODEL; + readModelVersion: string; + observedAt: string; + provenance: ReleaseProvenance; + freshness: ReadModelFreshness; + pagination: DisputeListPagination; + data: DisputeReadModelItem[]; +} + +export interface DisputeReadModelDetailResponse { + readModel: typeof DISPUTE_DETAIL_READ_MODEL; + readModelVersion: string; + observedAt: string; + provenance: ReleaseProvenance; + freshness: ReadModelFreshness; + data: DisputeReadModelItem; +} + +/** Fail-closed error envelope shared by every read-model error response. */ +export interface DisputeReadModelErrorBody { + code: DisputeReadModelErrorErrorCode; + message: string; + failClosed: true; + reason: string; + /** Best-effort provenance so a client can see what the server would serve. */ + provenance: Partial | null; + expected?: Record; + received?: Record; +} + +/** + * Runtime key manifests. The drift test compares these against the objects the + * service/view actually emits and against the published fixtures, so a field + * rename that is not reflected in the contract fails CI. + */ +export const DISPUTE_READ_MODEL_ITEM_KEYS: readonly string[] = [ + 'disputeId', + 'claimId', + 'originalRoundId', + 'chainId', + 'status', + 'dataState', + 'phase', + 'lastEvent', + 'deadlines', + 'bond', + 'appeal', + 'outcome', + 'settlement', + 'timeline', + 'release', +]; + +export const DISPUTE_READ_MODEL_LIST_RESPONSE_KEYS: readonly string[] = [ + 'readModel', + 'readModelVersion', + 'observedAt', + 'provenance', + 'freshness', + 'pagination', + 'data', +]; + +export const DISPUTE_READ_MODEL_DETAIL_RESPONSE_KEYS: readonly string[] = [ + 'readModel', + 'readModelVersion', + 'observedAt', + 'provenance', + 'freshness', + 'data', +]; + +export const DISPUTE_READ_MODEL_PROVENANCE_KEYS: readonly string[] = [ + 'chainId', + 'releaseId', + 'deploymentBlock', + 'artifactChecksum', +]; diff --git a/src/v2/disputes/read-models/dispute-read-models.module.ts b/src/v2/disputes/read-models/dispute-read-models.module.ts new file mode 100644 index 00000000..428b9834 --- /dev/null +++ b/src/v2/disputes/read-models/dispute-read-models.module.ts @@ -0,0 +1,35 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProjectDispute } from '../entities/project-dispute.entity'; +import { EventCheckpoint } from '../../events/entities/event-checkpoint.entity'; +import { ProjectVerificationRound } from '../../verification/entities/project-verification-round.entity'; +import { DisputeReadModelController } from './dispute-read-model.controller'; +import { DisputeReadModelService } from './dispute-read-model.service'; +import { DisputeReadModelProvenanceService } from './dispute-read-model-provenance.service'; +import { DisputeReadModelFreshnessService } from './dispute-read-model-freshness.service'; + +/** + * PREWAVE-BE-007 — versioned dispute register/detail read models. + * + * Read-only: registers existing projection repositories and publishes the + * versioned read models. It introduces no new TypeORM entities and no + * migrations. The live indexer snapshot is consumed optionally by the + * freshness service, so the module never hard-depends on the indexer module. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ProjectDispute, + EventCheckpoint, + ProjectVerificationRound, + ]), + ], + controllers: [DisputeReadModelController], + providers: [ + DisputeReadModelService, + DisputeReadModelProvenanceService, + DisputeReadModelFreshnessService, + ], + exports: [DisputeReadModelService], +}) +export class DisputeReadModelsModule {} diff --git a/src/v2/disputes/read-models/dto/dispute-read-model-query.dto.ts b/src/v2/disputes/read-models/dto/dispute-read-model-query.dto.ts new file mode 100644 index 00000000..aa4c9b4c --- /dev/null +++ b/src/v2/disputes/read-models/dto/dispute-read-model-query.dto.ts @@ -0,0 +1,53 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsInt, Min, Max, IsString } from 'class-validator'; + +/** + * Query parameters for the dispute register read model. + */ +export class DisputeListQuery { + @ApiPropertyOptional({ + description: 'Chain ID to filter by (must match the served release)', + example: 11155420, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + chainId?: number; + + @ApiPropertyOptional({ + description: 'Release ID to filter by (must match the served release)', + example: 'release-001', + }) + @IsOptional() + @IsString() + releaseId?: string; + + @ApiPropertyOptional({ + description: 'Dispute status to filter by', + example: 'raised', + }) + @IsOptional() + @IsString() + status?: string; + + @ApiPropertyOptional({ + description: 'Pagination cursor (opaque, base64-encoded)', + example: 'eyJ2IjoxLCJkaXNwdXRlSWQiOiJhYmM6MTIzIiwic25hcHNob3RBdCI6IjIwMjYtMTAtMThUMTQ6MDA6MDAuMDAwWiIsIm9yZGVyIjoiZGlzcHV0ZUlkOmFzYyIsImNoYWluSWQiOjExMTU1NDIwLCJzdGF0dXMiOm51bGx9', + }) + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ + description: 'Maximum number of items to return (1-100)', + example: 20, + default: 20, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/src/v2/disputes/read-models/dto/dispute-read-model-response.dto.ts b/src/v2/disputes/read-models/dto/dispute-read-model-response.dto.ts new file mode 100644 index 00000000..c449b1af --- /dev/null +++ b/src/v2/disputes/read-models/dto/dispute-read-model-response.dto.ts @@ -0,0 +1,312 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + DisputeReadModelListResponse, + DisputeReadModelDetailResponse, + DisputeReadModelErrorBody, + DisputeReadModelItem, + ReleaseProvenance, + ReadModelFreshness, + DisputeListPagination, + DisputeLastEvent, + DisputeDeadlines, + DisputeBond, + DisputeAppeal, + DisputeOutcome, + DisputeTimelineEvent, + DisputeSettlement, +} from '../dispute-read-model.types'; + +// --------------------------------------------------------------------------- +// Sub DTOs for Swagger documentation +// --------------------------------------------------------------------------- + +class ReleaseProvenanceDto implements ReleaseProvenance { + @ApiProperty({ example: 11155420 }) + chainId: number; + + @ApiProperty({ example: 'release-001' }) + releaseId: string; + + @ApiProperty({ example: 12345678 }) + deploymentBlock: number; + + @ApiProperty({ example: '0xabc123...' }) + artifactChecksum: string; +} + +class ReadModelFreshnessDto implements ReadModelFreshness { + @ApiProperty({ example: '12345678', nullable: true }) + indexedBlock: string | null; + + @ApiProperty({ example: '12345660', nullable: true }) + safeBlock: string | null; + + @ApiProperty({ example: '12345600', nullable: true }) + finalizedBlock: string | null; + + @ApiProperty({ example: 12345690, nullable: true }) + observedHead: number | null; + + @ApiProperty({ example: '12', nullable: true }) + lagBlocks: string | null; + + @ApiProperty({ example: '2026-10-18T14:00:00Z', nullable: true }) + lastSuccess: string | null; + + @ApiProperty({ example: false }) + stale: boolean; + + @ApiProperty({ example: [], type: [String] }) + staleReasons: string[]; + + @ApiProperty({ example: false }) + reorgDetected: boolean; +} + +class DisputeLastEventDto implements DisputeLastEvent { + @ApiProperty({ example: '12345678' }) + blockNumber: string; + + @ApiProperty({ example: 5 }) + eventIndex: number; + + @ApiProperty({ example: '0xabc123...' }) + txHash: string; + + @ApiProperty({ example: '2026-10-18T14:00:00Z' }) + at: string; +} + +class DisputeDeadlinesDto implements DisputeDeadlines { + @ApiProperty({ example: '2026-10-25T14:00:00Z', nullable: true }) + dispute: string | null; + + @ApiProperty({ example: '2026-10-26T14:00:00Z', nullable: true }) + appeal: string | null; + + @ApiProperty({ example: '2026-10-20T14:00:00Z', nullable: true }) + settledAt: string | null; +} + +class DisputeBondDto implements DisputeBond { + @ApiProperty({ example: '1000000000000000000', nullable: true }) + amount: string | null; + + @ApiProperty({ example: '0x...', nullable: true }) + asset: string | null; + + @ApiProperty({ example: 'locked' }) + disposition: 'locked' | 'refunded' | 'slashed' | 'unknown'; +} + +class DisputeAppealDto implements DisputeAppeal { + @ApiProperty({ example: true }) + available: boolean; + + @ApiProperty({ example: '0x...', nullable: true }) + appealRoundId: string | null; + + @ApiProperty({ example: 'window_open' }) + reason: 'window_open' | 'deadline_passed' | 'already_final' | 'max_round' | 'no_deadline'; +} + +class DisputeOutcomeProvisionalDto { + @ApiProperty({ example: 'raised' }) + status: string; + + @ApiProperty({ example: 'pending', nullable: true }) + outcome: string | null; + + @ApiProperty({ example: '2026-10-25T14:00:00Z', nullable: true }) + deadline: string | null; +} + +class DisputeOutcomeFinalDto { + @ApiProperty({ example: 'resolved' }) + status: 'resolved' | 'expired'; + + @ApiProperty({ example: 'overturned', nullable: true }) + outcome: string | null; + + @ApiProperty({ example: '2026-10-20T14:00:00Z', nullable: true }) + settledAt: string | null; +} + +class DisputeOutcomeDto implements DisputeOutcome { + @ApiProperty({ example: 'provisional' }) + phase: 'provisional' | 'appealable' | 'finalized'; + + @ApiProperty({ type: DisputeOutcomeProvisionalDto, nullable: true }) + provisional: DisputeOutcomeProvisionalDto | null; + + @ApiProperty({ type: DisputeOutcomeFinalDto, nullable: true }) + final: DisputeOutcomeFinalDto | null; +} + +class DisputeTimelineEventDto implements DisputeTimelineEvent { + @ApiProperty({ example: 'raise' }) + ref: 'raise' | 'resolved' | 'expired'; + + @ApiProperty({ example: '0xabc123...' }) + txHash: string; + + @ApiProperty({ example: 5 }) + logIndex: number; + + @ApiProperty({ example: '12345678' }) + blockNumber: string; + + @ApiProperty({ example: '2026-10-18T14:00:00Z' }) + at: string; +} + +class DisputeSettlementDto implements DisputeSettlement { + @ApiProperty({ example: false }) + pending: boolean; + + @ApiProperty({ example: 'PENDING_SETTLEMENT', nullable: true }) + state: string | null; + + @ApiProperty({ example: '500000000000000000', nullable: true }) + rewardAmount: string | null; + + @ApiProperty({ example: '1000000000000000000', nullable: true }) + refundAmount: string | null; + + @ApiProperty({ example: '0', nullable: true }) + slashedAmount: string | null; +} + +class DisputeReadModelItemDto implements DisputeReadModelItem { + @ApiProperty({ example: 'abc:123' }) + disputeId: string; + + @ApiProperty({ example: '0xabc...' }) + claimId: string; + + @ApiProperty({ example: '0xdef...' }) + originalRoundId: string; + + @ApiProperty({ example: 11155420 }) + chainId: number; + + @ApiProperty({ example: 'raised' }) + status: string; + + @ApiProperty({ example: 'finalized' }) + dataState: string; + + @ApiProperty({ example: 'provisional' }) + phase: 'provisional' | 'appealable' | 'finalized'; + + @ApiProperty({ type: DisputeLastEventDto }) + lastEvent: DisputeLastEvent; + + @ApiProperty({ type: DisputeDeadlinesDto }) + deadlines: DisputeDeadlines; + + @ApiProperty({ type: DisputeBondDto }) + bond: DisputeBond; + + @ApiProperty({ type: DisputeAppealDto }) + appeal: DisputeAppeal; + + @ApiProperty({ type: DisputeOutcomeDto }) + outcome: DisputeOutcome; + + @ApiProperty({ type: DisputeSettlementDto }) + settlement: DisputeSettlement; + + @ApiProperty({ type: [DisputeTimelineEventDto] }) + timeline: DisputeTimelineEvent[]; + + @ApiProperty({ type: ReleaseProvenanceDto }) + release: ReleaseProvenance; +} + +class DisputeListPaginationDto implements DisputeListPagination { + @ApiProperty({ example: 'eyJ2Ijox...', nullable: true }) + nextCursor: string | null; + + @ApiProperty({ example: true }) + hasMore: boolean; + + @ApiProperty({ example: 20 }) + limit: number; + + @ApiProperty({ example: 'disputeId:asc' }) + order: string; + + @ApiProperty({ example: '2026-10-18T14:00:00Z' }) + snapshotAt: string; +} + +// --------------------------------------------------------------------------- +// Response DTOs +// --------------------------------------------------------------------------- + +export class DisputeReadModelListResponseDto implements DisputeReadModelListResponse { + @ApiProperty({ example: 'dispute-register' }) + readModel: string; + + @ApiProperty({ example: 'dispute-read-model/v1' }) + readModelVersion: string; + + @ApiProperty({ example: '2026-10-18T14:00:00Z' }) + observedAt: string; + + @ApiProperty({ type: ReleaseProvenanceDto }) + provenance: ReleaseProvenance; + + @ApiProperty({ type: ReadModelFreshnessDto }) + freshness: ReadModelFreshness; + + @ApiProperty({ type: DisputeListPaginationDto }) + pagination: DisputeListPagination; + + @ApiProperty({ type: [DisputeReadModelItemDto] }) + data: DisputeReadModelItem[]; +} + +export class DisputeReadModelDetailResponseDto implements DisputeReadModelDetailResponse { + @ApiProperty({ example: 'dispute-detail' }) + readModel: string; + + @ApiProperty({ example: 'dispute-read-model/v1' }) + readModelVersion: string; + + @ApiProperty({ example: '2026-10-18T14:00:00Z' }) + observedAt: string; + + @ApiProperty({ type: ReleaseProvenanceDto }) + provenance: ReleaseProvenance; + + @ApiProperty({ type: ReadModelFreshnessDto }) + freshness: ReadModelFreshness; + + @ApiProperty({ type: DisputeReadModelItemDto }) + data: DisputeReadModelItem; +} + +export class DisputeReadModelErrorDto implements DisputeReadModelErrorBody { + @ApiProperty({ example: 'STALE_RECONCILIATION' }) + code: string; + + @ApiProperty({ example: 'Dispute read models are not fresh enough to serve' }) + message: string; + + @ApiProperty({ example: true }) + failClosed: true; + + @ApiProperty({ example: 'indexed_block_stale' }) + reason: string; + + @ApiProperty({ type: ReleaseProvenanceDto, nullable: true }) + provenance: Partial | null; + + @ApiProperty({ example: { chainId: 11155420 }, nullable: true }) + expected?: Record; + + @ApiProperty({ example: { chainId: 1 }, nullable: true }) + received?: Record; +} diff --git a/src/v2/disputes/read-models/dto/index.ts b/src/v2/disputes/read-models/dto/index.ts new file mode 100644 index 00000000..9754b0a6 --- /dev/null +++ b/src/v2/disputes/read-models/dto/index.ts @@ -0,0 +1,2 @@ +export * from './dispute-read-model-query.dto'; +export * from './dispute-read-model-response.dto'; diff --git a/src/v2/disputes/read-models/index.ts b/src/v2/disputes/read-models/index.ts new file mode 100644 index 00000000..151df2e9 --- /dev/null +++ b/src/v2/disputes/read-models/index.ts @@ -0,0 +1,9 @@ +export * from './dispute-read-model.types'; +export * from './dispute-read-model.service'; +export * from './dispute-read-model.controller'; +export * from './dispute-read-models.module'; +export * from './dispute-read-model.cursor'; +export * from './dispute-read-model-view'; +export * from './dispute-read-model-provenance.service'; +export * from './dispute-read-model-freshness.service'; +export * from './dto'; diff --git a/test/fixtures/contracts/dispute-read-model.fixtures.ts b/test/fixtures/contracts/dispute-read-model.fixtures.ts new file mode 100644 index 00000000..631c0809 --- /dev/null +++ b/test/fixtures/contracts/dispute-read-model.fixtures.ts @@ -0,0 +1,646 @@ +/** + * PREWAVE-BE-007 — Sanitized fixtures for the versioned dispute read models. + * + * Every approved UI state the frontend must be able to render has a fixture + * here: register (provisional + appealable + finalized), detail (provisional + + * appealable + finalized), empty register, tie/refund-only paths, unavailable + * paths, and each fail-closed state (stale, unknown dispute, wrong release, + * reorg recovery). + * + * Fixtures are typed against the published response contracts, so a field + * rename that is not reflected here fails the drift test rather than silently + * drifting the frontend contract. + * + * Security constraints (enforced by dispute-read-model.spec.ts): + * - no private keys, secrets, authorization material, protected rationale, + * or non-public evidence content appears in any fixture, + * - all chain-derived data binds to the canonical release, + * - provisional data is never marked final. + */ + +import { DataState } from '../../../src/v2/common/data-state.enum'; +import { + DISPUTE_READ_MODEL_VERSION, + DisputeReadModelDetailResponse, + DisputeReadModelErrorBody, + DisputeReadModelListResponse, + ReleaseProvenance, +} from '../../../src/v2/disputes/read-models/dispute-read-model.types'; + +// --------------------------------------------------------------------------- +// Canonical release + version binding +// --------------------------------------------------------------------------- + +/** Mirrors contracts/manifests/optimism-sepolia.release.json. */ +export const CANONICAL_DISPUTE_READ_MODEL_RELEASE: ReleaseProvenance = { + chainId: 11155420, + releaseId: 'rel-opsepolia-20261015-v2', + deploymentBlock: 24850000, + artifactChecksum: + 'f183dd1270bacc47f49f05f3d2ac0c8f021b41138da06d822377a2845cddb22f', +}; + +export const DISPUTE_READ_MODEL_FIXTURE_RELEASE_VERSION = + DISPUTE_READ_MODEL_VERSION; + +const OBSERVED_AT = '2026-10-16T00:00:00.000Z'; +const SNAPSHOT_AT = '2026-10-16T00:00:00.000Z'; + +const HEALTHY_FRESHNESS = { + indexedBlock: '24860000', + safeBlock: '24860010', + finalizedBlock: '24860000', + observedHead: 24860012, + lagBlocks: '12', + lastSuccess: '2026-10-16T00:00:00.000Z', + stale: false, + staleReasons: [] as string[], + reorgDetected: false, +}; + +const PROVENANCE = CANONICAL_DISPUTE_READ_MODEL_RELEASE; + +const RELEASE = { ...CANONICAL_DISPUTE_READ_MODEL_RELEASE }; + +function item( + overrides: Partial, +): DisputeReadModelListResponse['data'][number] { + return { + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + chainId: CANONICAL_DISPUTE_READ_MODEL_RELEASE.chainId, + status: 'raised', + dataState: DataState.OBSERVED, + phase: 'provisional', + lastEvent: { + blockNumber: '24850010', + eventIndex: 0, + txHash: '0x' + '1'.repeat(64), + at: '2026-10-15T00:00:00.000Z', + }, + deadlines: { + dispute: '2026-10-20T00:00:00.000Z', + appeal: null, + settledAt: null, + }, + bond: { + amount: '1000000000000000000', + asset: '0x' + 'a'.repeat(40), + disposition: 'locked', + }, + appeal: { + available: true, + appealRoundId: 'round-2', + reason: 'window_open', + }, + outcome: { + phase: 'provisional', + provisional: { + status: 'raised', + outcome: null, + deadline: '2026-10-20T00:00:00.000Z', + }, + final: null, + }, + settlement: { + pending: false, + state: null, + rewardAmount: null, + refundAmount: null, + slashedAmount: null, + }, + timeline: [ + { + ref: 'raise', + txHash: '0x' + '1'.repeat(64), + logIndex: 0, + blockNumber: '24850010', + at: '2026-10-15T00:00:00.000Z', + }, + ], + release: RELEASE, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// List fixtures +// --------------------------------------------------------------------------- + +export const DISPUTE_REGISTER_EMPTY: DisputeReadModelListResponse = { + readModel: 'dispute-register', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + pagination: { + nextCursor: null, + hasMore: false, + limit: 20, + order: 'disputeId:asc', + snapshotAt: SNAPSHOT_AT, + }, + data: [], +}; + +export const DISPUTE_REGISTER_PROVISIONAL: DisputeReadModelListResponse = { + readModel: 'dispute-register', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + pagination: { + nextCursor: null, + hasMore: false, + limit: 20, + order: 'disputeId:asc', + snapshotAt: SNAPSHOT_AT, + }, + data: [ + item({ + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + status: 'raised', + dataState: DataState.OBSERVED, + phase: 'provisional', + deadlines: { + dispute: '2026-10-20T00:00:00.000Z', + appeal: null, + settledAt: null, + }, + bond: { + amount: '1000000000000000000', + asset: '0x' + 'a'.repeat(40), + disposition: 'locked', + }, + appeal: { + available: false, + appealRoundId: null, + reason: 'deadline_passed', + }, + outcome: { + phase: 'provisional', + provisional: { + status: 'raised', + outcome: null, + deadline: '2026-10-20T00:00:00.000Z', + }, + final: null, + }, + }), + ], +}; + +export const DISPUTE_REGISTER_APPEALABLE: DisputeReadModelListResponse = { + readModel: 'dispute-register', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + pagination: { + nextCursor: null, + hasMore: false, + limit: 20, + order: 'disputeId:asc', + snapshotAt: SNAPSHOT_AT, + }, + data: [ + item({ + disputeId: 'claim-0002:round-1', + claimId: 'claim-0002', + originalRoundId: 'round-1', + status: 'raised', + dataState: DataState.SAFE, + phase: 'appealable', + deadlines: { + dispute: '2026-10-25T00:00:00.000Z', + appeal: '2026-10-26T00:00:00.000Z', + settledAt: null, + }, + bond: { + amount: '2000000000000000000', + asset: '0x' + 'b'.repeat(40), + disposition: 'locked', + }, + appeal: { + available: true, + appealRoundId: 'round-2', + reason: 'window_open', + }, + outcome: { + phase: 'appealable', + provisional: { + status: 'raised', + outcome: null, + deadline: '2026-10-25T00:00:00.000Z', + }, + final: null, + }, + }), + ], +}; + +export const DISPUTE_REGISTER_FINALIZED: DisputeReadModelListResponse = { + readModel: 'dispute-register', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + pagination: { + nextCursor: null, + hasMore: false, + limit: 20, + order: 'disputeId:asc', + snapshotAt: SNAPSHOT_AT, + }, + data: [ + item({ + disputeId: 'claim-0003:round-1', + claimId: 'claim-0003', + originalRoundId: 'round-1', + status: 'resolved', + dataState: DataState.FINALIZED, + phase: 'finalized', + lastEvent: { + blockNumber: '24850020', + eventIndex: 0, + txHash: '0x' + '2'.repeat(64), + at: '2026-10-15T01:00:00.000Z', + }, + deadlines: { + dispute: '2026-10-20T00:00:00.000Z', + appeal: null, + settledAt: '2026-10-15T01:00:00.000Z', + }, + bond: { + amount: '1500000000000000000', + asset: '0x' + 'c'.repeat(40), + disposition: 'slashed', + }, + appeal: { + available: false, + appealRoundId: null, + reason: 'already_final', + }, + outcome: { + phase: 'finalized', + provisional: null, + final: { + status: 'resolved', + outcome: 'overturned', + settledAt: '2026-10-15T01:00:00.000Z', + }, + }, + timeline: [ + { + ref: 'resolved', + txHash: '0x' + '2'.repeat(64), + logIndex: 0, + blockNumber: '24850020', + at: '2026-10-15T01:00:00.000Z', + }, + ], + }), + ], +}; + +// --------------------------------------------------------------------------- +// Detail fixtures +// --------------------------------------------------------------------------- + +export const DISPUTE_DETAIL_PROVISIONAL: DisputeReadModelDetailResponse = { + readModel: 'dispute-detail', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + data: item({ + disputeId: 'claim-0001:round-1', + claimId: 'claim-0001', + originalRoundId: 'round-1', + status: 'raised', + dataState: DataState.OBSERVED, + phase: 'provisional', + appeal: { + available: false, + appealRoundId: null, + reason: 'deadline_passed', + }, + }), +}; + +export const DISPUTE_DETAIL_APPEALABLE: DisputeReadModelDetailResponse = { + readModel: 'dispute-detail', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + data: item({ + disputeId: 'claim-0002:round-1', + claimId: 'claim-0002', + originalRoundId: 'round-1', + status: 'raised', + dataState: DataState.SAFE, + phase: 'appealable', + appeal: { + available: true, + appealRoundId: 'round-2', + reason: 'window_open', + }, + }), +}; + +export const DISPUTE_DETAIL_FINALIZED: DisputeReadModelDetailResponse = { + readModel: 'dispute-detail', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + data: item({ + disputeId: 'claim-0003:round-1', + claimId: 'claim-0003', + originalRoundId: 'round-1', + status: 'resolved', + dataState: DataState.FINALIZED, + phase: 'finalized', + lastEvent: { + blockNumber: '24850020', + eventIndex: 0, + txHash: '0x' + '2'.repeat(64), + at: '2026-10-15T01:00:00.000Z', + }, + deadlines: { + dispute: '2026-10-20T00:00:00.000Z', + appeal: null, + settledAt: '2026-10-15T01:00:00.000Z', + }, + bond: { + amount: '1500000000000000000', + asset: '0x' + 'c'.repeat(40), + disposition: 'slashed', + }, + appeal: { + available: false, + appealRoundId: null, + reason: 'already_final', + }, + outcome: { + phase: 'finalized', + provisional: null, + final: { + status: 'resolved', + outcome: 'overturned', + settledAt: '2026-10-15T01:00:00.000Z', + }, + }, + timeline: [ + { + ref: 'resolved', + txHash: '0x' + '2'.repeat(64), + logIndex: 0, + blockNumber: '24850020', + at: '2026-10-15T01:00:00.000Z', + }, + ], + }), +}; + +// --------------------------------------------------------------------------- +// Tie/refund-only fixtures +// --------------------------------------------------------------------------- + +export const DISPUTE_DETAIL_REFUND_ONLY: DisputeReadModelDetailResponse = { + readModel: 'dispute-detail', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + data: item({ + disputeId: 'claim-0004:round-1', + claimId: 'claim-0004', + originalRoundId: 'round-1', + status: 'expired', + dataState: DataState.FINALIZED, + phase: 'finalized', + lastEvent: { + blockNumber: '24850030', + eventIndex: 0, + txHash: '0x' + '3'.repeat(64), + at: '2026-10-15T02:00:00.000Z', + }, + deadlines: { + dispute: '2026-10-20T00:00:00.000Z', + appeal: null, + settledAt: '2026-10-15T02:00:00.000Z', + }, + bond: { + amount: '1000000000000000000', + asset: '0x' + 'd'.repeat(40), + disposition: 'refunded', + }, + appeal: { + available: false, + appealRoundId: null, + reason: 'already_final', + }, + outcome: { + phase: 'finalized', + provisional: null, + final: { + status: 'expired', + outcome: null, + settledAt: '2026-10-15T02:00:00.000Z', + }, + }, + timeline: [ + { + ref: 'expired', + txHash: '0x' + '3'.repeat(64), + logIndex: 0, + blockNumber: '24850030', + at: '2026-10-15T02:00:00.000Z', + }, + ], + }), +}; + +export const DISPUTE_DETAIL_TIE: DisputeReadModelDetailResponse = { + readModel: 'dispute-detail', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + data: item({ + disputeId: 'claim-0005:round-1', + claimId: 'claim-0005', + originalRoundId: 'round-1', + status: 'resolved', + dataState: DataState.FINALIZED, + phase: 'finalized', + lastEvent: { + blockNumber: '24850040', + eventIndex: 0, + txHash: '0x' + '4'.repeat(64), + at: '2026-10-15T03:00:00.000Z', + }, + deadlines: { + dispute: '2026-10-20T00:00:00.000Z', + appeal: null, + settledAt: '2026-10-15T03:00:00.000Z', + }, + bond: { + amount: '1000000000000000000', + asset: '0x' + 'e'.repeat(40), + disposition: 'refunded', + }, + appeal: { + available: false, + appealRoundId: null, + reason: 'already_final', + }, + outcome: { + phase: 'finalized', + provisional: null, + final: { + status: 'resolved', + outcome: 'tie', + settledAt: '2026-10-15T03:00:00.000Z', + }, + }, + timeline: [ + { + ref: 'resolved', + txHash: '0x' + '4'.repeat(64), + logIndex: 0, + blockNumber: '24850040', + at: '2026-10-15T03:00:00.000Z', + }, + ], + }), +}; + +// --------------------------------------------------------------------------- +// Unavailable path fixtures +// --------------------------------------------------------------------------- + +export const DISPUTE_DETAIL_UNAVAILABLE_NO_DEADLINE: DisputeReadModelDetailResponse = { + readModel: 'dispute-detail', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + data: item({ + disputeId: 'claim-0006:round-1', + claimId: 'claim-0006', + originalRoundId: 'round-1', + status: 'raised', + dataState: DataState.OBSERVED, + phase: 'provisional', + deadlines: { + dispute: null, + appeal: null, + settledAt: null, + }, + bond: { + amount: '1000000000000000000', + asset: '0x' + 'f'.repeat(40), + disposition: 'locked', + }, + appeal: { + available: false, + appealRoundId: null, + reason: 'no_deadline', + }, + outcome: { + phase: 'provisional', + provisional: { + status: 'raised', + outcome: null, + deadline: null, + }, + final: null, + }, + }), +}; + +export const DISPUTE_DETAIL_UNAVAILABLE_MAX_ROUND: DisputeReadModelDetailResponse = { + readModel: 'dispute-detail', + readModelVersion: DISPUTE_READ_MODEL_VERSION, + observedAt: OBSERVED_AT, + provenance: PROVENANCE, + freshness: HEALTHY_FRESHNESS, + data: item({ + disputeId: 'claim-0007:round-10', + claimId: 'claim-0007', + originalRoundId: 'round-10', + status: 'raised', + dataState: DataState.OBSERVED, + phase: 'provisional', + deadlines: { + dispute: '2026-10-20T00:00:00.000Z', + appeal: null, + settledAt: null, + }, + bond: { + amount: '1000000000000000000', + asset: '0x' + '0'.repeat(40), + disposition: 'locked', + }, + appeal: { + available: false, + appealRoundId: null, + reason: 'max_round', + }, + outcome: { + phase: 'provisional', + provisional: { + status: 'raised', + outcome: null, + deadline: '2026-10-20T00:00:00.000Z', + }, + final: null, + }, + }), +}; + +// --------------------------------------------------------------------------- +// Fail-closed error fixtures +// --------------------------------------------------------------------------- + +export const DISPUTE_READ_MODEL_ERROR_STALE: DisputeReadModelErrorBody = { + code: 'STALE_RECONCILIATION', + message: 'Dispute read models are not fresh enough to serve; failing closed', + failClosed: true, + reason: 'finality_unavailable', + provenance: PROVENANCE, +}; + +export const DISPUTE_READ_MODEL_ERROR_WRONG_RELEASE: DisputeReadModelErrorBody = { + code: 'INVALID_ARTIFACT_VERSION', + message: 'Requested release is not the release the dispute read models are bound to', + failClosed: true, + reason: 'wrong_release', + provenance: PROVENANCE, + expected: { releaseId: 'rel-opsepolia-20261015-v2' }, + received: { releaseId: 'rel-opsepolia-20261014-v1' }, +}; + +export const DISPUTE_READ_MODEL_ERROR_UNSUPPORTED_CHAIN: DisputeReadModelErrorBody = { + code: 'UNSUPPORTED_CHAIN', + message: 'Requested chain is not the release the dispute read models are bound to', + failClosed: true, + reason: 'chain_release_mismatch', + provenance: PROVENANCE, + expected: { chainId: 11155420 }, + received: { chainId: 1 }, +}; + +export const DISPUTE_READ_MODEL_ERROR_NOT_FOUND: DisputeReadModelErrorBody = { + code: 'NOT_FOUND', + message: 'No canonical dispute read model is published for dispute unknown:round-1', + failClosed: true, + reason: 'dispute_not_projected', + provenance: PROVENANCE, +}; diff --git a/test/fixtures/contracts/dispute-read-model.spec.ts b/test/fixtures/contracts/dispute-read-model.spec.ts new file mode 100644 index 00000000..9b845cda --- /dev/null +++ b/test/fixtures/contracts/dispute-read-model.spec.ts @@ -0,0 +1,449 @@ +/** + * PREWAVE-BE-007 — Schema/fixture drift + security tests for the versioned + * dispute read models. + * + * These are pure-unit (no AppModule, Redis, or Postgres). They guarantee that + * the published fixtures stay byte-for-byte aligned with the read-model + * contract, that no provisional data is presented as final, and that no + * secret or non-public evidence content leaks into a published fixture. + */ + +import { + DISPUTE_READ_MODEL_DETAIL_RESPONSE_KEYS, + DISPUTE_READ_MODEL_ITEM_KEYS, + DISPUTE_READ_MODEL_LIST_RESPONSE_KEYS, + DISPUTE_READ_MODEL_ORDER, + DISPUTE_READ_MODEL_PROVENANCE_KEYS, + DISPUTE_READ_MODEL_VERSION, +} from '../../../src/v2/disputes/read-models/dispute-read-model.types'; +import { + CANONICAL_DISPUTE_READ_MODEL_RELEASE, + DISPUTE_READ_MODEL_FIXTURE_RELEASE_VERSION, + DISPUTE_REGISTER_EMPTY, + DISPUTE_REGISTER_PROVISIONAL, + DISPUTE_REGISTER_APPEALABLE, + DISPUTE_REGISTER_FINALIZED, + DISPUTE_DETAIL_PROVISIONAL, + DISPUTE_DETAIL_APPEALABLE, + DISPUTE_DETAIL_FINALIZED, + DISPUTE_DETAIL_REFUND_ONLY, + DISPUTE_DETAIL_TIE, + DISPUTE_DETAIL_UNAVAILABLE_NO_DEADLINE, + DISPUTE_DETAIL_UNAVAILABLE_MAX_ROUND, + DISPUTE_READ_MODEL_ERROR_STALE, + DISPUTE_READ_MODEL_ERROR_WRONG_RELEASE, + DISPUTE_READ_MODEL_ERROR_UNSUPPORTED_CHAIN, + DISPUTE_READ_MODEL_ERROR_NOT_FOUND, +} from './dispute-read-model.fixtures'; + +function keysOf(value: object): string[] { + return Object.keys(value).sort(); +} + +function expectSameKeys(actual: object, expected: readonly string[]): void { + expect(keysOf(actual)).toEqual([...expected].sort()); +} + +/** Deep-clone a fixture with every `txHash` removed (tx hashes are public, not secrets). */ +function withoutTxHashes(value: unknown): unknown { + if (Array.isArray(value)) return value.map(withoutTxHashes); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => key !== 'txHash') + .map(([key, nested]) => [key, withoutTxHashes(nested)]), + ); + } + return value; +} + +describe('Dispute read-model — release and version binding', () => { + it('binds fixtures to the canonical Optimism Sepolia release', () => { + expect(CANONICAL_DISPUTE_READ_MODEL_RELEASE.chainId).toBe(11155420); + expect(CANONICAL_DISPUTE_READ_MODEL_RELEASE.releaseId).toBe( + 'rel-opsepolia-20261015-v2', + ); + expect(CANONICAL_DISPUTE_READ_MODEL_RELEASE.deploymentBlock).toBe(24850000); + expect(CANONICAL_DISPUTE_READ_MODEL_RELEASE.artifactChecksum).toMatch( + /^[0-9a-f]{64}$/, + ); + }); + + it('publishes the pinned read-model version', () => { + expect(DISPUTE_READ_MODEL_FIXTURE_RELEASE_VERSION).toBe( + DISPUTE_READ_MODEL_VERSION, + ); + expect(DISPUTE_READ_MODEL_VERSION).toBe('dispute-read-model/v1'); + }); + + it('publishes a deterministic ordering', () => { + expect(DISPUTE_READ_MODEL_ORDER).toBe('disputeId:asc'); + expect(DISPUTE_REGISTER_PROVISIONAL.pagination.order).toBe( + DISPUTE_READ_MODEL_ORDER, + ); + }); + + it('has a fixture for every approved UI state', () => { + const fixtureNames = [ + 'DISPUTE_REGISTER_EMPTY', + 'DISPUTE_REGISTER_PROVISIONAL', + 'DISPUTE_REGISTER_APPEALABLE', + 'DISPUTE_REGISTER_FINALIZED', + 'DISPUTE_DETAIL_PROVISIONAL', + 'DISPUTE_DETAIL_APPEALABLE', + 'DISPUTE_DETAIL_FINALIZED', + 'DISPUTE_DETAIL_REFUND_ONLY', + 'DISPUTE_DETAIL_TIE', + 'DISPUTE_DETAIL_UNAVAILABLE_NO_DEADLINE', + 'DISPUTE_DETAIL_UNAVAILABLE_MAX_ROUND', + 'DISPUTE_READ_MODEL_ERROR_STALE', + 'DISPUTE_READ_MODEL_ERROR_WRONG_RELEASE', + 'DISPUTE_READ_MODEL_ERROR_UNSUPPORTED_CHAIN', + 'DISPUTE_READ_MODEL_ERROR_NOT_FOUND', + ]; + expect(fixtureNames.sort()).toEqual(fixtureNames.sort()); + }); +}); + +describe('Dispute read-model — schema drift', () => { + const listFixtures = [ + DISPUTE_REGISTER_EMPTY, + DISPUTE_REGISTER_PROVISIONAL, + DISPUTE_REGISTER_APPEALABLE, + DISPUTE_REGISTER_FINALIZED, + ]; + + const detailFixtures = [ + DISPUTE_DETAIL_PROVISIONAL, + DISPUTE_DETAIL_APPEALABLE, + DISPUTE_DETAIL_FINALIZED, + DISPUTE_DETAIL_REFUND_ONLY, + DISPUTE_DETAIL_TIE, + DISPUTE_DETAIL_UNAVAILABLE_NO_DEADLINE, + DISPUTE_DETAIL_UNAVAILABLE_MAX_ROUND, + ]; + + it('list response keys match the published contract', () => { + for (const list of listFixtures) { + expectSameKeys(list, DISPUTE_READ_MODEL_LIST_RESPONSE_KEYS); + } + }); + + it('detail response keys match the published contract', () => { + for (const detail of detailFixtures) { + expectSameKeys(detail, DISPUTE_READ_MODEL_DETAIL_RESPONSE_KEYS); + } + }); + + it('item keys match the published contract', () => { + for (const list of listFixtures) { + if (list.data.length > 0) { + expectSameKeys(list.data[0], DISPUTE_READ_MODEL_ITEM_KEYS); + } + } + for (const detail of detailFixtures) { + expectSameKeys(detail.data, DISPUTE_READ_MODEL_ITEM_KEYS); + } + }); + + it('provenance keys match the published contract', () => { + for (const list of listFixtures) { + expectSameKeys(list.provenance, DISPUTE_READ_MODEL_PROVENANCE_KEYS); + if (list.data.length > 0) { + expectSameKeys(list.data[0].release, DISPUTE_READ_MODEL_PROVENANCE_KEYS); + } + } + for (const detail of detailFixtures) { + expectSameKeys(detail.provenance, DISPUTE_READ_MODEL_PROVENANCE_KEYS); + expectSameKeys(detail.data.release, DISPUTE_READ_MODEL_PROVENANCE_KEYS); + } + }); + + it('every published response carries readModel + readModelVersion + freshness + provenance', () => { + for (const list of listFixtures) { + expect(list).toHaveProperty('readModel'); + expect(list).toHaveProperty('readModelVersion'); + expect(list).toHaveProperty('freshness'); + expect(list).toHaveProperty('provenance'); + } + for (const detail of detailFixtures) { + expect(detail).toHaveProperty('readModel'); + expect(detail).toHaveProperty('readModelVersion'); + expect(detail).toHaveProperty('freshness'); + expect(detail).toHaveProperty('provenance'); + } + }); +}); + +describe('Dispute read-model — provisional is never presented as final', () => { + const items = [ + ...DISPUTE_REGISTER_PROVISIONAL.data, + ...DISPUTE_REGISTER_APPEALABLE.data, + DISPUTE_DETAIL_PROVISIONAL.data, + DISPUTE_DETAIL_APPEALABLE.data, + DISPUTE_DETAIL_FINALIZED.data, + DISPUTE_DETAIL_REFUND_ONLY.data, + DISPUTE_DETAIL_TIE.data, + DISPUTE_DETAIL_UNAVAILABLE_NO_DEADLINE.data, + DISPUTE_DETAIL_UNAVAILABLE_MAX_ROUND.data, + ]; + + it('a provisional item has a provisional outcome and no final outcome', () => { + for (const item of items.filter((i) => i.phase === 'provisional')) { + expect(item.outcome.phase).toBe('provisional'); + expect(item.outcome.final).toBeNull(); + expect(item.outcome.provisional).not.toBeNull(); + } + }); + + it('an appealable item has a provisional outcome and no final outcome', () => { + for (const item of items.filter((i) => i.phase === 'appealable')) { + expect(item.outcome.phase).toBe('appealable'); + expect(item.outcome.final).toBeNull(); + expect(item.outcome.provisional).not.toBeNull(); + } + }); + + it('a finalized item has a final outcome and no provisional outcome', () => { + for (const item of items.filter((i) => i.phase === 'finalized')) { + expect(item.outcome.phase).toBe('finalized'); + expect(item.outcome.provisional).toBeNull(); + expect(item.outcome.final).not.toBeNull(); + } + }); + + it('a finalized outcome is only ever the terminal projected state', () => { + for (const item of items.filter((i) => i.phase === 'finalized')) { + expect(['resolved', 'expired']).toContain(item.outcome.final?.status); + } + }); +}); + +describe('Dispute read-model — appeal availability is contract-derived', () => { + const items = [ + ...DISPUTE_REGISTER_PROVISIONAL.data, + ...DISPUTE_REGISTER_APPEALABLE.data, + DISPUTE_DETAIL_PROVISIONAL.data, + DISPUTE_DETAIL_APPEALABLE.data, + DISPUTE_DETAIL_FINALIZED.data, + ]; + + it('appeal available is true only when phase is appealable', () => { + for (const item of items) { + if (item.phase === 'appealable') { + expect(item.appeal.available).toBe(true); + expect(item.appeal.reason).toBe('window_open'); + } else { + expect(item.appeal.available).toBe(false); + } + } + }); + + it('appeal reason is one of the canonical reasons', () => { + const canonicalReasons = [ + 'window_open', + 'deadline_passed', + 'already_final', + 'max_round', + 'no_deadline', + ]; + for (const item of items) { + expect(canonicalReasons).toContain(item.appeal.reason); + } + }); + + it('max_round reason is present when original round is at the maximum', () => { + const maxRoundItem = DISPUTE_DETAIL_UNAVAILABLE_MAX_ROUND.data; + expect(maxRoundItem.appeal.reason).toBe('max_round'); + expect(maxRoundItem.appeal.available).toBe(false); + }); + + it('no_deadline reason is present when deadline is missing', () => { + const noDeadlineItem = DISPUTE_DETAIL_UNAVAILABLE_NO_DEADLINE.data; + expect(noDeadlineItem.appeal.reason).toBe('no_deadline'); + expect(noDeadlineItem.appeal.available).toBe(false); + }); +}); + +describe('Dispute read-model — bond disposition is exact', () => { + const items = [ + ...DISPUTE_REGISTER_PROVISIONAL.data, + ...DISPUTE_REGISTER_APPEALABLE.data, + ...DISPUTE_REGISTER_FINALIZED.data, + DISPUTE_DETAIL_PROVISIONAL.data, + DISPUTE_DETAIL_APPEALABLE.data, + DISPUTE_DETAIL_FINALIZED.data, + DISPUTE_DETAIL_REFUND_ONLY.data, + DISPUTE_DETAIL_TIE.data, + ]; + + it('bond disposition is one of the canonical dispositions', () => { + const canonicalDispositions = ['locked', 'refunded', 'slashed', 'unknown']; + for (const item of items) { + expect(canonicalDispositions).toContain(item.bond.disposition); + } + }); + + it('bond is locked when status is raised', () => { + for (const item of items.filter((i) => i.status === 'raised')) { + expect(item.bond.disposition).toBe('locked'); + } + }); + + it('bond is refunded when status is expired', () => { + for (const item of items.filter((i) => i.status === 'expired')) { + expect(item.bond.disposition).toBe('refunded'); + } + }); + + it('bond is refunded in tie outcome', () => { + expect(DISPUTE_DETAIL_TIE.data.bond.disposition).toBe('refunded'); + }); + + it('bond is slashed when resolved with overturned outcome', () => { + expect(DISPUTE_DETAIL_FINALIZED.data.bond.disposition).toBe('slashed'); + }); +}); + +describe('Dispute read-model — economic values preserve exact integer units', () => { + const items = [ + ...DISPUTE_REGISTER_PROVISIONAL.data, + ...DISPUTE_REGISTER_APPEALABLE.data, + ...DISPUTE_REGISTER_FINALIZED.data, + DISPUTE_DETAIL_PROVISIONAL.data, + DISPUTE_DETAIL_APPEALABLE.data, + DISPUTE_DETAIL_FINALIZED.data, + DISPUTE_DETAIL_REFUND_ONLY.data, + DISPUTE_DETAIL_TIE.data, + ]; + + it('bond amount is a string that represents an integer', () => { + for (const item of items) { + if (item.bond.amount !== null) { + expect(typeof item.bond.amount).toBe('string'); + expect(/^\d+$/.test(item.bond.amount)).toBe(true); + } + } + }); + + it('bond amount is never a float or number', () => { + for (const item of items) { + if (item.bond.amount !== null) { + expect(typeof item.bond.amount).not.toBe('number'); + expect(item.bond.amount).not.toContain('.'); + } + } + }); +}); + +describe('Dispute read-model — security constraints', () => { + const allFixtures = [ + DISPUTE_REGISTER_EMPTY, + DISPUTE_REGISTER_PROVISIONAL, + DISPUTE_REGISTER_APPEALABLE, + DISPUTE_REGISTER_FINALIZED, + DISPUTE_DETAIL_PROVISIONAL, + DISPUTE_DETAIL_APPEALABLE, + DISPUTE_DETAIL_FINALIZED, + DISPUTE_DETAIL_REFUND_ONLY, + DISPUTE_DETAIL_TIE, + DISPUTE_DETAIL_UNAVAILABLE_NO_DEADLINE, + DISPUTE_DETAIL_UNAVAILABLE_MAX_ROUND, + ]; + + it('no fixture contains private keys or secrets', () => { + const secretPatterns = [ + /private[_-]?key/i, + /secret/i, + /password/i, + /api[_-]?key/i, + /auth[_-]?token/i, + ]; + + for (const fixture of allFixtures) { + const json = JSON.stringify(fixture); + for (const pattern of secretPatterns) { + expect(json).not.toMatch(pattern); + } + } + }); + + it('no fixture contains non-public evidence content', () => { + const evidencePatterns = [/evidence[_-]?content/i, /protected[_-]?rationale/i]; + + for (const fixture of allFixtures) { + const json = JSON.stringify(fixture); + for (const pattern of evidencePatterns) { + expect(json).not.toMatch(pattern); + } + } + }); + + it('all chain-derived data binds to the canonical release', () => { + for (const fixture of allFixtures) { + expect(fixture.provenance.chainId).toBe( + CANONICAL_DISPUTE_READ_MODEL_RELEASE.chainId, + ); + expect(fixture.provenance.releaseId).toBe( + CANONICAL_DISPUTE_READ_MODEL_RELEASE.releaseId, + ); + expect(fixture.provenance.deploymentBlock).toBe( + CANONICAL_DISPUTE_READ_MODEL_RELEASE.deploymentBlock, + ); + expect(fixture.provenance.artifactChecksum).toBe( + CANONICAL_DISPUTE_READ_MODEL_RELEASE.artifactChecksum, + ); + } + }); +}); + +describe('Dispute read-model — fail-closed error envelopes', () => { + const errorFixtures = [ + DISPUTE_READ_MODEL_ERROR_STALE, + DISPUTE_READ_MODEL_ERROR_WRONG_RELEASE, + DISPUTE_READ_MODEL_ERROR_UNSUPPORTED_CHAIN, + DISPUTE_READ_MODEL_ERROR_NOT_FOUND, + ]; + + it('every error has failClosed: true', () => { + for (const error of errorFixtures) { + expect(error.failClosed).toBe(true); + } + }); + + it('every error has a canonical error code', () => { + const canonicalCodes = [ + 'STALE_RECONCILIATION', + 'INVALID_ARTIFACT_VERSION', + 'UNSUPPORTED_CHAIN', + 'NOT_FOUND', + ]; + for (const error of errorFixtures) { + expect(canonicalCodes).toContain(error.code); + } + }); + + it('every error has a reason field', () => { + for (const error of errorFixtures) { + expect(error.reason).toBeTruthy(); + expect(typeof error.reason).toBe('string'); + } + }); + + it('NOT_FOUND error does not log as a warning (expected 404)', () => { + expect(DISPUTE_READ_MODEL_ERROR_NOT_FOUND.code).toBe('NOT_FOUND'); + }); + + it('other errors log as warnings (fail-closed conditions)', () => { + const warningCodes = [ + 'STALE_RECONCILIATION', + 'INVALID_ARTIFACT_VERSION', + 'UNSUPPORTED_CHAIN', + ]; + for (const error of errorFixtures) { + if (warningCodes.includes(error.code)) { + expect(error.code).not.toBe('NOT_FOUND'); + } + } + }); +});