Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "DriftIncident" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"kind" TEXT NOT NULL,
"severity" TEXT NOT NULL,
"campaignId" TEXT,
"packageId" TEXT,
"onchainSnapshot" JSONB NOT NULL,
"backendSnapshot" JSONB NOT NULL,
"description" TEXT NOT NULL,
"resolution" TEXT NOT NULL DEFAULT 'unresolved',
"resolvedBy" TEXT,
"resolvedAt" DATETIME,
"resolutionNotes" TEXT,
CONSTRAINT "DriftIncident_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);

-- CreateIndex
CREATE INDEX "DriftIncident_campaignId_idx" ON "DriftIncident"("campaignId");

-- CreateIndex
CREATE INDEX "DriftIncident_kind_idx" ON "DriftIncident"("kind");

-- CreateIndex
CREATE INDEX "DriftIncident_severity_idx" ON "DriftIncident"("severity");

-- CreateIndex
CREATE INDEX "DriftIncident_resolution_idx" ON "DriftIncident"("resolution");

-- CreateIndex
CREATE INDEX "DriftIncident_createdAt_idx" ON "DriftIncident"("createdAt");

-- CreateIndex
CREATE INDEX "DriftIncident_packageId_idx" ON "DriftIncident"("packageId");
68 changes: 68 additions & 0 deletions app/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,74 @@ model BalanceLedger {
@@index([createdAt])
}

enum DriftSeverity {
low
medium
high
critical
}

enum DriftKind {
status_mismatch
locked_total_mismatch
package_missing_onchain
package_missing_backend
amount_mismatch
}

enum DriftResolution {
unresolved
auto_reconciled
manually_resolved
ignored
}

/// Records every detected divergence between on-chain and backend cached state.
model DriftIncident {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

/// The kind of drift detected
kind DriftKind

/// Severity classification
severity DriftSeverity

/// Optional campaign this drift relates to
campaignId String?
campaign Campaign? @relation(fields: [campaignId], references: [id])

/// Optional package ID involved
packageId String?

/// JSON blob with on-chain snapshot at time of detection
onchainSnapshot Json

/// JSON blob with backend/cached snapshot at time of detection
backendSnapshot Json

/// Human-readable description of the drift
description String

/// Resolution status
resolution DriftResolution @default(unresolved)

/// Who or what resolved this incident
resolvedBy String?
resolvedAt DateTime?

/// Free-form notes from resolution
resolutionNotes String?

@@index([campaignId])
@@index([kind])
@@index([severity])
@@index([resolution])
@@index([createdAt])
@@index([packageId])
}

enum VerificationChannel {
email
phone
Expand Down
124 changes: 124 additions & 0 deletions app/backend/src/onchain/ledger-admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Get,
Param,
Body,
Query,
Version,
HttpCode,
HttpStatus,
Expand All @@ -17,9 +18,11 @@ import {
ApiBadRequestResponse,
ApiUnauthorizedResponse,
ApiForbiddenResponse,
ApiQuery,
} from '@nestjs/swagger';
import { LedgerBackfillService } from './ledger-backfill.service';
import { LedgerReconciliationService } from './ledger-reconciliation.service';
import { StateReconciliationService } from './state-reconciliation.service';
import { Roles } from '../auth/roles.decorator';
import { AppRole } from '../auth/app-role.enum';

Expand All @@ -29,6 +32,7 @@ export class LedgerAdminController {
constructor(
private readonly backfillService: LedgerBackfillService,
private readonly reconciliationService: LedgerReconciliationService,
private readonly stateReconciliationService: StateReconciliationService,
) {}

@Post('backfill')
Expand Down Expand Up @@ -251,4 +255,124 @@ export class LedgerAdminController {
}
return status;
}

/* ================================================================ */
/* State reconciliation (on-chain ↔ backend drift detection) */
/* ================================================================ */

@Post('state-reconciliation')
@Version('1')
@Roles(AppRole.admin)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Trigger on-chain ↔ backend state reconciliation',
description:
'Compares every AidPackage status and campaign locked-total against the on-chain contract, persists any drift incidents, and returns the report.',
})
@ApiBody({
required: false,
schema: {
type: 'object',
properties: {
campaignId: {
type: 'string',
description:
'Optional – scope reconciliation to a single campaign',
},
},
},
})
@ApiOkResponse({
description: 'Reconciliation completed.',
schema: {
example: {
triggeredAt: '2026-05-27T12:00:00.000Z',
durationMs: 1234,
driftsDetected: 1,
drifts: [
{
packageId: 'pkg_abc',
kind: 'status_mismatch',
severity: 'high',
description: '…',
},
],
},
},
})
@ApiUnauthorizedResponse({ description: 'Unauthorized.' })
@ApiForbiddenResponse({ description: 'Admin role required.' })
async triggerStateReconciliation(
@Body() body?: { campaignId?: string },
) {
return this.stateReconciliationService.reconcile(body?.campaignId);
}

@Get('drift-incidents')
@Version('1')
@Roles(AppRole.admin)
@ApiOperation({
summary: 'List recorded drift incidents',
description:
'Returns a paginated list of drift incidents, filterable by campaign, kind, severity, and resolution status.',
})
@ApiQuery({ name: 'campaignId', required: false })
@ApiQuery({ name: 'kind', required: false })
@ApiQuery({ name: 'severity', required: false })
@ApiQuery({ name: 'resolution', required: false })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
@ApiOkResponse({ description: 'Drift incident list.' })
@ApiUnauthorizedResponse({ description: 'Unauthorized.' })
@ApiForbiddenResponse({ description: 'Admin role required.' })
async listDriftIncidents(
@Query('campaignId') campaignId?: string,
@Query('kind') kind?: string,
@Query('severity') severity?: string,
@Query('resolution') resolution?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.stateReconciliationService.getDriftHistory({
campaignId,
kind,
severity,
resolution,
limit: limit ? parseInt(limit, 10) : undefined,
offset: offset ? parseInt(offset, 10) : undefined,
});
}

@Post('drift-incidents/:id/resolve')
@Version('1')
@Roles(AppRole.admin)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Resolve a drift incident',
description: 'Mark a drift incident as manually resolved with optional notes.',
})
@ApiParam({ name: 'id', description: 'Drift incident ID' })
@ApiBody({
schema: {
type: 'object',
properties: {
resolvedBy: { type: 'string' },
resolutionNotes: { type: 'string' },
},
required: ['resolvedBy'],
},
})
@ApiOkResponse({ description: 'Incident resolved.' })
@ApiUnauthorizedResponse({ description: 'Unauthorized.' })
@ApiForbiddenResponse({ description: 'Admin role required.' })
async resolveDriftIncident(
@Param('id') id: string,
@Body() body: { resolvedBy: string; resolutionNotes?: string },
) {
return this.stateReconciliationService.resolveDrift(
id,
body.resolvedBy,
body.resolutionNotes,
);
}
}
3 changes: 3 additions & 0 deletions app/backend/src/onchain/onchain.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { OnchainProcessor } from './onchain.processor';
import { OnchainService } from './onchain.service';
import { LedgerBackfillService } from './ledger-backfill.service';
import { LedgerReconciliationService } from './ledger-reconciliation.service';
import { StateReconciliationService } from './state-reconciliation.service';
import { LedgerAdminController } from './ledger-admin.controller';
import { JobsModule } from '../jobs/jobs.module';

Expand Down Expand Up @@ -64,12 +65,14 @@ const onchainAdapterProvider: Provider = {
OnchainService,
LedgerBackfillService,
LedgerReconciliationService,
StateReconciliationService,
],
exports: [
ONCHAIN_ADAPTER_TOKEN,
OnchainService,
LedgerBackfillService,
LedgerReconciliationService,
StateReconciliationService,
],
})
export class OnchainModule {}
Loading
Loading