Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import { ReputationModule } from './reputation/reputation.module';
import { GovernanceModule } from './governance/governance.module';
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
import { AdminModule } from './admin/admin.module';
import { V2EventsModule } from './v2/events/v2-events.module';
import { V2EvidenceModule } from './v2/evidence/v2-evidence.module';
import { V2VerificationModule } from './v2/verification/v2-verification.module';
import { V2DisputesModule } from './v2/disputes/v2-disputes.module';
import { ProfilerModule } from './profiler/profiler.module';
import { ProfilerInterceptor } from './profiler/profiler.interceptor';
import { HealthModule } from './health/health.module';
Expand Down Expand Up @@ -342,6 +346,10 @@ async function createThrottlerStorage(
GovernanceModule,
AiAssistantModule,
AdminModule,
V2EventsModule,
V2EvidenceModule,
V2VerificationModule,
V2DisputesModule,
ProfilerModule,
HealthModule,
FeatureFlagsModule,
Expand Down
97 changes: 97 additions & 0 deletions src/migrations/1769800000000-CreateV2CanonicalEventTables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* V2-BE-011: canonical event decode/normalize pipeline tables.
*
* v2_contract_artifacts and v2_event_checkpoints are minimal stand-ins for
* the not-yet-merged V2-BE-008 / V2-BE-010 interfaces (see PR description).
*/
export class CreateV2CanonicalEventTables1769800000000 implements MigrationInterface {
name = 'CreateV2CanonicalEventTables1769800000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "v2_contract_artifacts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"chainId" integer NOT NULL,
"contractAddress" varchar(42) NOT NULL,
"artifactVersion" varchar(64) NOT NULL,
"abi" json NOT NULL,
"isApproved" boolean NOT NULL DEFAULT false,
"registeredAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "uq_v2_contract_artifact_address" UNIQUE ("chainId", "contractAddress")
)
`);
await queryRunner.query(
`CREATE INDEX "idx_v2_contract_artifacts_is_approved" ON "v2_contract_artifacts" ("isApproved")`,
);

await queryRunner.query(`
CREATE TABLE "v2_event_checkpoints" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"chainId" integer NOT NULL,
"contractAddress" varchar(42) NOT NULL,
"lastSafeBlock" bigint NOT NULL DEFAULT 0,
"lastFinalizedBlock" bigint NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "uq_v2_checkpoint_source" UNIQUE ("chainId", "contractAddress")
)
`);

await queryRunner.query(`
CREATE TABLE "v2_canonical_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"chainId" integer NOT NULL,
"contractAddress" varchar(42) NOT NULL,
"artifactVersion" varchar(64) NOT NULL,
"eventName" varchar(128) NOT NULL,
"txHash" varchar(66) NOT NULL,
"logIndex" integer NOT NULL,
"blockNumber" bigint NOT NULL,
"blockTimestamp" TIMESTAMP NULL,
"actor" varchar(42) NULL,
"claimId" varchar(66) NULL,
"roundId" varchar(66) NULL,
"asset" varchar(42) NULL,
"amount" varchar(100) NULL,
"payload" json NOT NULL,
"rawArgs" json NOT NULL,
"ingestedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "uq_v2_canonical_event_identity" UNIQUE ("chainId", "txHash", "logIndex")
)
`);
await queryRunner.query(
`CREATE INDEX "idx_v2_canonical_events_block_log" ON "v2_canonical_events" ("blockNumber", "logIndex")`,
);
await queryRunner.query(
`CREATE INDEX "idx_v2_canonical_events_name_block" ON "v2_canonical_events" ("eventName", "blockNumber")`,
);
await queryRunner.query(`CREATE INDEX "idx_v2_canonical_events_claim_id" ON "v2_canonical_events" ("claimId")`);
await queryRunner.query(`CREATE INDEX "idx_v2_canonical_events_round_id" ON "v2_canonical_events" ("roundId")`);

await queryRunner.query(`
CREATE TABLE "v2_event_quarantine" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"chainId" integer NOT NULL,
"contractAddress" varchar(42) NOT NULL,
"txHash" varchar(66) NOT NULL,
"logIndex" integer NOT NULL,
"blockNumber" bigint NOT NULL,
"topic0" varchar(66) NULL,
"reason" varchar(32) NOT NULL,
"rawLog" json NOT NULL,
"detail" text NULL,
"quarantinedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "uq_v2_quarantine_identity" UNIQUE ("chainId", "txHash", "logIndex")
)
`);
await queryRunner.query(`CREATE INDEX "idx_v2_event_quarantine_reason" ON "v2_event_quarantine" ("reason")`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "v2_event_quarantine"`);
await queryRunner.query(`DROP TABLE "v2_canonical_events"`);
await queryRunner.query(`DROP TABLE "v2_event_checkpoints"`);
await queryRunner.query(`DROP TABLE "v2_contract_artifacts"`);
}
}
58 changes: 58 additions & 0 deletions src/migrations/1769800100000-CreateV2EvidenceTables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/** V2-BE-013: evidence read model tables, plus the shared projector cursor table. */
export class CreateV2EvidenceTables1769800100000 implements MigrationInterface {
name = 'CreateV2EvidenceTables1769800100000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "v2_projector_cursors" (
"projectorName" varchar(64) PRIMARY KEY,
"lastBlockNumber" bigint NOT NULL DEFAULT 0,
"lastLogIndex" integer NOT NULL DEFAULT -1,
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
)
`);

await queryRunner.query(`
CREATE TABLE "v2_project_evidence" (
"evidenceId" varchar(128) PRIMARY KEY,
"claimId" varchar(66) NOT NULL,
"currentVersion" integer NOT NULL DEFAULT 1,
"status" varchar(16) NOT NULL DEFAULT 'active',
"contentDigest" varchar(66) NOT NULL,
"lastEventBlockNumber" bigint NOT NULL,
"lastEventLogIndex" integer NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`CREATE INDEX "idx_v2_project_evidence_claim_id" ON "v2_project_evidence" ("claimId")`);

await queryRunner.query(`
CREATE TABLE "v2_project_evidence_version" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"evidenceId" varchar(128) NOT NULL,
"version" integer NOT NULL,
"contentDigest" varchar(66) NOT NULL,
"safeMetadataUri" varchar(512) NULL,
"submittedBy" varchar(42) NULL,
"eventTxHash" varchar(66) NOT NULL,
"eventLogIndex" integer NOT NULL,
"blockNumber" bigint NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "uq_v2_evidence_version" UNIQUE ("evidenceId", "version"),
CONSTRAINT "uq_v2_evidence_version_event" UNIQUE ("eventTxHash", "eventLogIndex")
)
`);
await queryRunner.query(
`CREATE INDEX "idx_v2_project_evidence_version_evidence_id" ON "v2_project_evidence_version" ("evidenceId")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "v2_project_evidence_version"`);
await queryRunner.query(`DROP TABLE "v2_project_evidence"`);
await queryRunner.query(`DROP TABLE "v2_projector_cursors"`);
}
}
74 changes: 74 additions & 0 deletions src/migrations/1769800200000-CreateV2VerificationTables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/** V2-BE-014: verification round/position read model tables, plus the shared anomaly log. */
export class CreateV2VerificationTables1769800200000 implements MigrationInterface {
name = 'CreateV2VerificationTables1769800200000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "v2_indexing_anomalies" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"sourceModule" varchar(64) NOT NULL,
"kind" varchar(32) NOT NULL,
"aggregateId" varchar(128) NOT NULL,
"eventTxHash" varchar(66) NOT NULL,
"eventLogIndex" integer NOT NULL,
"detail" text NOT NULL,
"detectedAt" TIMESTAMP NOT NULL DEFAULT now()
)
`);
await queryRunner.query(
`CREATE INDEX "idx_v2_indexing_anomalies_source_kind" ON "v2_indexing_anomalies" ("sourceModule", "kind")`,
);
await queryRunner.query(
`CREATE INDEX "idx_v2_indexing_anomalies_aggregate_id" ON "v2_indexing_anomalies" ("aggregateId")`,
);

await queryRunner.query(`
CREATE TABLE "v2_project_verification_round" (
"roundId" varchar(66) PRIMARY KEY,
"claimId" varchar(66) NOT NULL,
"roundType" varchar(16) NOT NULL,
"roundNumber" integer NOT NULL,
"deadline" TIMESTAMP NULL,
"status" varchar(16) NOT NULL DEFAULT 'open',
"openedAtBlock" bigint NOT NULL,
"eventTxHash" varchar(66) NOT NULL,
"eventLogIndex" integer NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "uq_v2_round_sequence" UNIQUE ("claimId", "roundType", "roundNumber")
)
`);
await queryRunner.query(
`CREATE INDEX "idx_v2_verification_round_claim_id" ON "v2_project_verification_round" ("claimId")`,
);

await queryRunner.query(`
CREATE TABLE "v2_project_participant_position" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"roundId" varchar(66) NOT NULL,
"participant" varchar(42) NOT NULL,
"stake" varchar(100) NOT NULL,
"reputationInput" varchar(100) NULL,
"effectiveWeight" varchar(100) NULL,
"position" varchar(32) NULL,
"eventTxHash" varchar(66) NOT NULL,
"eventLogIndex" integer NOT NULL,
"blockNumber" bigint NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "uq_v2_position_event" UNIQUE ("eventTxHash", "eventLogIndex"),
CONSTRAINT "uq_v2_position_participant_round" UNIQUE ("roundId", "participant")
)
`);
await queryRunner.query(
`CREATE INDEX "idx_v2_participant_position_round_id" ON "v2_project_participant_position" ("roundId")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "v2_project_participant_position"`);
await queryRunner.query(`DROP TABLE "v2_project_verification_round"`);
await queryRunner.query(`DROP TABLE "v2_indexing_anomalies"`);
}
}
31 changes: 31 additions & 0 deletions src/migrations/1769800300000-CreateV2DisputesTables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/** V2-BE-016: dispute/appeal lifecycle read model table. */
export class CreateV2DisputesTables1769800300000 implements MigrationInterface {
name = 'CreateV2DisputesTables1769800300000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "v2_project_dispute" (
"disputeId" varchar(200) PRIMARY KEY,
"claimId" varchar(66) NOT NULL,
"originalRoundId" varchar(66) NOT NULL,
"appealRoundId" varchar(66) NULL,
"challengeBond" varchar(100) NULL,
"challengeBondAsset" varchar(42) NULL,
"status" varchar(16) NOT NULL DEFAULT 'raised',
"deadline" TIMESTAMP NULL,
"resolvedOutcome" varchar(64) NULL,
"eventTxHash" varchar(66) NOT NULL,
"eventLogIndex" integer NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`CREATE INDEX "idx_v2_project_dispute_claim_id" ON "v2_project_dispute" ("claimId")`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "v2_project_dispute"`);
}
}
61 changes: 61 additions & 0 deletions src/v2/common/cursor-pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { BadRequestException } from '@nestjs/common';

/**
* Deterministic keyset pagination for V2 event-derived read models.
*
* All V2 projections are ordered by chain-native coordinates
* (blockNumber, logIndex) with the row id as a final tiebreaker, never by
* offset/page number. Offset pagination silently skips or duplicates rows
* when new events land between requests; a keyset cursor over immutable
* ordering coordinates does not.
*/
export interface OrderKey {
blockNumber: string;
logIndex: number;
id: string;
}

export interface CursorPage<T> {
items: T[];
nextCursor: string | null;
}

/** Encode an order key into an opaque, URL-safe cursor string. */
export function encodeCursor(key: OrderKey): string {
// blockNumber may arrive as a JS number under sqlite (used only in tests)
// even though the column is declared bigint; normalize to string so the
// cursor format is stable regardless of the underlying driver.
const raw = JSON.stringify([String(key.blockNumber), key.logIndex, key.id]);
return Buffer.from(raw, 'utf8').toString('base64url');
}

/** Decode a cursor string produced by {@link encodeCursor}. Throws on tampering. */
export function decodeCursor(cursor: string): OrderKey {
try {
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
const [blockNumber, logIndex, id] = JSON.parse(raw) as [
string,
number,
string,
];
if (
typeof blockNumber !== 'string' ||
typeof logIndex !== 'number' ||
typeof id !== 'string'
) {
throw new Error('malformed cursor payload');
}
return { blockNumber, logIndex, id };
} catch {
throw new BadRequestException('Invalid pagination cursor');
}
}

export const DEFAULT_PAGE_SIZE = 25;
export const MAX_PAGE_SIZE = 100;

export function clampPageSize(requested?: number): number {
if (!requested || Number.isNaN(requested) || requested <= 0)
return DEFAULT_PAGE_SIZE;
return Math.min(requested, MAX_PAGE_SIZE);
}
Loading
Loading