diff --git a/src/db/migrations/005_control_plane_flags.sql b/src/db/migrations/005_control_plane_flags.sql new file mode 100644 index 0000000..3c04b82 --- /dev/null +++ b/src/db/migrations/005_control_plane_flags.sql @@ -0,0 +1,12 @@ +-- Migration 005: durable control-plane flag store +-- +-- Used by ContractSpecRevalidator to persist the contract spec validation +-- state (valid / mismatch / unknown) across restarts. The block survives +-- a process restart and clears only after a successful revalidation or +-- an explicit operator override. + +CREATE TABLE IF NOT EXISTS control_plane_flags ( + key VARCHAR(80) PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/src/stellar/contract-spec-revalidator.spec.ts b/src/stellar/contract-spec-revalidator.spec.ts new file mode 100644 index 0000000..9815f98 --- /dev/null +++ b/src/stellar/contract-spec-revalidator.spec.ts @@ -0,0 +1,322 @@ +/** + * Tests for ContractSpecRevalidator: + * 1. Mocked spec change blocks publication and settlement, fires alert + * 2. RPC errors produce "unknown" (not "mismatch") and also block + * 3. Block survives a restart (persisted state loaded on init) + * 4. Diff-report correctness + * 5. Operator override clears the block + */ + +import { SchedulerRegistry } from "@nestjs/schedule"; +import { rpc } from "@stellar/stellar-sdk"; +import { ConfigService } from "@nestjs/config"; +import { AppConfig } from "../config/configuration"; +import { AlertingService } from "../alerting/alerting.service"; +import { DbService } from "../db/db.service"; +import { ContractSpecRevalidator, RevalidationResult } from "./contract-spec-revalidator"; +import * as PoolContractInterface from "./pool-contract-interface.service"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function buildConfig(overrides: Partial = {}): ConfigService { + return { + get: jest.fn((key: string) => { + if (key === "stellar") { + return { + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + poolContractId: "CPOOL0000000000000000000000000000000000000000000000000000", + oracleContractId: "CORACLE000000000000000000000000000000000000000000000000", + ...overrides, + }; + } + if (key === "contractRevalidation") return { intervalSeconds: 300 }; + return undefined; + }), + } as unknown as ConfigService; +} + +function buildAlerting(): jest.Mocked { + return { emit: jest.fn(), listRecent: jest.fn() } as unknown as jest.Mocked; +} + +function buildDb(persistedState?: RevalidationResult): jest.Mocked { + return { + isAvailable: jest.fn().mockReturnValue(true), + query: jest.fn().mockImplementation((sql: string) => { + if (sql.includes("SELECT value") && persistedState) { + return Promise.resolve({ rows: [{ value: JSON.stringify(persistedState) }] }); + } + return Promise.resolve({ rows: [] }); + }), + } as unknown as jest.Mocked; +} + +function buildSchedulerRegistry(): jest.Mocked { + return { + addCronJob: jest.fn(), + doesExist: jest.fn().mockReturnValue(false), + deleteCronJob: jest.fn(), + } as unknown as jest.Mocked; +} + +function buildRevalidator( + overrides: Partial = {}, + persistedState?: RevalidationResult, +) { + const config = buildConfig(overrides); + const alerting = buildAlerting(); + const db = buildDb(persistedState); + const scheduler = buildSchedulerRegistry(); + const svc = new ContractSpecRevalidator(config, alerting, db, scheduler); + return { svc, alerting, db, scheduler }; +} + +function validWasm(): Buffer { + // Minimal valid WASM header (magic + version) — actual parsing is mocked. + return Buffer.from("0061736d01000000", "hex"); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("ContractSpecRevalidator", () => { + afterEach(() => jest.restoreAllMocks()); + + // 1. Mocked spec change blocks publication/settlement and fires alert ─────── + + it("blocks publication and fires critical alert on spec mismatch", async () => { + const { svc, alerting } = buildRevalidator(); + + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockResolvedValue(validWasm() as never); + jest.spyOn(PoolContractInterface, "parseContractSpec").mockReturnValue({} as never); + jest.spyOn(PoolContractInterface, "validatePoolContractSpec").mockReturnValue([ + "missing function buy_policy", + "process_claim arguments expected (policy_id:string) but found (id:u64)", + ]); + + await svc.revalidate(); + + expect(alerting.emit).toHaveBeenCalledWith( + "critical", + expect.stringContaining("mismatch"), + expect.stringContaining("buy_policy"), + expect.anything(), + ); + + expect(() => svc.assertPublicationAllowed()).toThrow(/blocked/); + expect(() => svc.assertSettlementAllowed()).toThrow(/blocked/); + }); + + it("includes correct diff entries for changed and removed functions", async () => { + const { svc } = buildRevalidator(); + + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockResolvedValue(validWasm() as never); + jest.spyOn(PoolContractInterface, "parseContractSpec").mockReturnValue({} as never); + jest.spyOn(PoolContractInterface, "validatePoolContractSpec").mockReturnValue([ + "missing function pool_config", + "buy_policy arguments expected (holder:address, params:udt:PolicyParams) but found (holder:address)", + ]); + + const result = await svc.revalidate(); + + expect(result.state).toBe("mismatch"); + const poolDiff = result.pool.diff; + expect(poolDiff).toContainEqual(expect.objectContaining({ name: "pool_config", change: "removed" })); + expect(poolDiff).toContainEqual(expect.objectContaining({ name: "buy_policy", change: "changed" })); + }); + + // 2. RPC errors produce "unknown", not "mismatch" ───────────────────────── + + it("returns unknown state when RPC is unavailable and blocks both operations", async () => { + const { svc, alerting } = buildRevalidator(); + + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:8000")); + + const result = await svc.revalidate(); + + expect(result.state).toBe("unknown"); + expect(result.pool.state).toBe("unknown"); + expect(alerting.emit).toHaveBeenCalledWith( + "warning", + expect.stringContaining("unknown"), + expect.anything(), + ); + + expect(() => svc.assertPublicationAllowed()).toThrow(/blocked/); + expect(() => svc.assertSettlementAllowed()).toThrow(/blocked/); + }); + + it("does not classify an RPC timeout as a mismatch", async () => { + const { svc } = buildRevalidator(); + + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockRejectedValue(new Error("Request timeout after 5000ms")); + + const result = await svc.revalidate(); + + expect(result.state).toBe("unknown"); + expect(result.pool.state).toBe("unknown"); + expect(result.pool.errors[0]).toMatch(/RPC unavailable/); + }); + + // 3. Block survives a restart ─────────────────────────────────────────────── + + it("loads persisted mismatch state on init and blocks immediately before first revalidation", async () => { + const blockedState: RevalidationResult = { + state: "mismatch", + pool: { + contractId: "CPOOL", + contractName: "pool", + state: "mismatch", + checkedAt: new Date().toISOString(), + errors: ["missing function buy_policy"], + diff: [{ name: "buy_policy", change: "removed" }], + wasmHash: null, + }, + oracle: { + contractId: "CORACLE", + contractName: "oracle", + state: "valid", + checkedAt: new Date().toISOString(), + errors: [], + diff: [], + wasmHash: null, + }, + blockedAt: new Date().toISOString(), + operatorOverride: null, + }; + + // Build with a DB that returns the persisted state. + const { svc } = buildRevalidator({}, blockedState); + + // Only load state — don't call revalidate() yet (simulating fresh restart + // before the scheduler's first tick). + await (svc as unknown as { loadPersistedState(): Promise }).loadPersistedState(); + + expect(() => svc.assertPublicationAllowed()).toThrow(/blocked/); + expect(() => svc.assertSettlementAllowed()).toThrow(/blocked/); + }); + + // 4. Diff-report correctness ─────────────────────────────────────────────── + + it("exposes diff report through getLastResult() after a mismatch", async () => { + const { svc } = buildRevalidator(); + + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockResolvedValue(validWasm() as never); + jest.spyOn(PoolContractInterface, "parseContractSpec").mockReturnValue({} as never); + jest.spyOn(PoolContractInterface, "validatePoolContractSpec").mockReturnValue([ + "process_claim result expected result:i128 but found void", + ]); + + await svc.revalidate(); + + const report = svc.getLastResult(); + expect(report).not.toBeNull(); + expect(report!.state).toBe("mismatch"); + expect(report!.pool.diff[0]).toEqual( + expect.objectContaining({ + name: "process_claim", + change: "changed", + expected: "result:i128", + actual: "void", + }), + ); + }); + + it("clears block and diff after a successful revalidation", async () => { + const { svc } = buildRevalidator(); + + // First: inject a mismatch. + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockResolvedValueOnce(validWasm() as never); + jest.spyOn(PoolContractInterface, "parseContractSpec").mockReturnValue({} as never); + jest + .spyOn(PoolContractInterface, "validatePoolContractSpec") + .mockReturnValueOnce(["missing function pool_config"]) + .mockReturnValue([]); + + await svc.revalidate(); + expect(svc.getLastResult()!.state).toBe("mismatch"); + + // Second: spec is now clean. + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockResolvedValue(validWasm() as never); + + await svc.revalidate(); + + expect(svc.getLastResult()!.state).toBe("valid"); + expect(() => svc.assertPublicationAllowed()).not.toThrow(); + expect(() => svc.assertSettlementAllowed()).not.toThrow(); + }); + + // 5. Operator override ───────────────────────────────────────────────────── + + it("operator override clears the publication block without requiring a clean spec", async () => { + const { svc, alerting } = buildRevalidator(); + + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockResolvedValue(validWasm() as never); + jest.spyOn(PoolContractInterface, "parseContractSpec").mockReturnValue({} as never); + jest.spyOn(PoolContractInterface, "validatePoolContractSpec").mockReturnValue(["missing function buy_policy"]); + + await svc.revalidate(); + expect(() => svc.assertPublicationAllowed()).toThrow(); + + await svc.applyOperatorOverride("Confirmed safe — hotfix pending", "ops-admin"); + + expect(() => svc.assertPublicationAllowed()).not.toThrow(); + expect(alerting.emit).toHaveBeenCalledWith( + "warning", + expect.stringContaining("overridden"), + expect.stringContaining("ops-admin"), + expect.anything(), + ); + }); + + it("removing the override restores the block", async () => { + const { svc } = buildRevalidator(); + + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockResolvedValue(validWasm() as never); + jest.spyOn(PoolContractInterface, "parseContractSpec").mockReturnValue({} as never); + jest.spyOn(PoolContractInterface, "validatePoolContractSpec").mockReturnValue(["missing function buy_policy"]); + + await svc.revalidate(); + await svc.applyOperatorOverride("temp", "admin"); + expect(() => svc.assertPublicationAllowed()).not.toThrow(); + + await svc.clearOperatorOverride(); + expect(() => svc.assertPublicationAllowed()).toThrow(/blocked/); + }); + + // 6. State persistence ───────────────────────────────────────────────────── + + it("persists state to DB after each revalidation", async () => { + const { svc, db } = buildRevalidator(); + + jest + .spyOn(rpc.Server.prototype, "getContractWasmByContractId") + .mockResolvedValue(validWasm() as never); + jest.spyOn(PoolContractInterface, "parseContractSpec").mockReturnValue({} as never); + jest.spyOn(PoolContractInterface, "validatePoolContractSpec").mockReturnValue([]); + + await svc.revalidate(); + + expect(db.query).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO control_plane_flags"), + expect.arrayContaining(["contract_spec_validation_state"]), + ); + }); +}); diff --git a/src/stellar/contract-spec-revalidator.ts b/src/stellar/contract-spec-revalidator.ts new file mode 100644 index 0000000..911d80a --- /dev/null +++ b/src/stellar/contract-spec-revalidator.ts @@ -0,0 +1,399 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { SchedulerRegistry } from "@nestjs/schedule"; +import { CronJob } from "cron"; +import { rpc } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { AlertingService } from "../alerting/alerting.service"; +import { DbService } from "../db/db.service"; +import { + validatePoolContractSpec, + parseContractSpec, + extractContractSpec, +} from "./pool-contract-interface.service"; + +/** + * Three possible states for the contract-spec validation control-plane flag. + * + * - valid: last check passed; publication and settlement are allowed. + * - mismatch: spec diverged from expected; publication and settlement are BLOCKED. + * - unknown: RPC was unreachable; we fail-closed (block) but distinguish + * this from a confirmed mismatch so operators can tell the + * difference between an upgrade and a connectivity blip. + */ +export type ValidationState = "valid" | "mismatch" | "unknown"; + +export interface FunctionDiff { + name: string; + change: "added" | "removed" | "changed"; + expected?: string; + actual?: string; +} + +export interface ContractDiffReport { + contractId: string; + contractName: "pool" | "oracle"; + state: ValidationState; + checkedAt: string; + errors: string[]; + diff: FunctionDiff[]; + wasmHash: string | null; +} + +export interface RevalidationResult { + state: ValidationState; + pool: ContractDiffReport; + oracle: ContractDiffReport; + blockedAt: string | null; + operatorOverride: OperatorOverride | null; +} + +export interface OperatorOverride { + reason: string; + actor: string; + overriddenAt: string; +} + +const REVALIDATION_JOB_NAME = "contract-spec-revalidation"; +const CONTROL_PLANE_KEY = "contract_spec_validation_state"; + +/** + * Periodically and on-demand revalidates the live Soroban contract specs for + * the pool and oracle contracts against the backend's expected ABI. + * + * A mismatch (or RPC unavailability) sets a durable block — persisted in + * Postgres — so the block survives restarts. It clears only after a + * successful revalidation or an explicit operator override. + * + * Callers (OraclePublisherService, ClaimSettlementService) inject this and + * call assertPublicationAllowed() / assertSettlementAllowed() at their gates. + */ +@Injectable() +export class ContractSpecRevalidator implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ContractSpecRevalidator.name); + private readonly server: rpc.Server; + private readonly poolContractId: string; + private readonly oracleContractId: string; + private readonly intervalSeconds: number; + + /** In-memory cache of the last result; DB is the durable source of truth. */ + private lastResult: RevalidationResult | null = null; + + constructor( + private readonly configService: ConfigService, + private readonly alerting: AlertingService, + private readonly db: DbService, + private readonly schedulerRegistry: SchedulerRegistry, + ) { + const stellar = configService.get("stellar", { infer: true }); + this.server = new rpc.Server(stellar.sorobanRpcUrl); + this.poolContractId = stellar.poolContractId; + this.oracleContractId = stellar.oracleContractId; + this.intervalSeconds = configService.get("contractRevalidation", { infer: true })?.intervalSeconds ?? 300; + } + + async onModuleInit(): Promise { + // Load any persisted state so the block survives a restart before the + // first scheduler tick. + await this.loadPersistedState(); + + if (!this.poolContractId && !this.oracleContractId) { + this.logger.warn("No contract IDs configured — skipping revalidation scheduler"); + return; + } + + // Run immediately at startup, then on the configured interval. + await this.revalidate(); + + const job = new CronJob(`*/${this.intervalSeconds} * * * * *`, () => { + void this.revalidate(); + }); + this.schedulerRegistry.addCronJob(REVALIDATION_JOB_NAME, job); + job.start(); + this.logger.log(`Contract spec revalidation scheduled every ${this.intervalSeconds}s`); + } + + onModuleDestroy(): void { + if (this.schedulerRegistry.doesExist("cron", REVALIDATION_JOB_NAME)) { + this.schedulerRegistry.deleteCronJob(REVALIDATION_JOB_NAME); + } + } + + // ── Public gate ─────────────────────────────────────────────────────────── + + /** + * Throws if the current validation state blocks publication. + * Called by OraclePublisherService.maybePublish(). + */ + assertPublicationAllowed(): void { + this.assertAllowed("publication"); + } + + /** + * Throws if the current validation state blocks settlement. + * Called by ClaimSettlementService before submitting a payout. + */ + assertSettlementAllowed(): void { + this.assertAllowed("settlement"); + } + + private assertAllowed(operation: string): void { + const result = this.lastResult; + if (!result) return; // Not yet initialised — allow (startup path handled separately) + + // Operator override clears the block unconditionally. + if (result.operatorOverride) return; + + if (result.state === "mismatch") { + throw new Error( + `Contract spec mismatch — ${operation} blocked. Run GET /ops/contracts/diff-report for details.`, + ); + } + if (result.state === "unknown") { + throw new Error( + `Contract spec validation state unknown (RPC unreachable) — ${operation} blocked until RPC recovers.`, + ); + } + } + + // ── Revalidation ────────────────────────────────────────────────────────── + + async revalidate(): Promise { + const [poolReport, oracleReport] = await Promise.all([ + this.checkContract("pool", this.poolContractId), + this.checkContract("oracle", this.oracleContractId), + ]); + + const anyMismatch = poolReport.state === "mismatch" || oracleReport.state === "mismatch"; + const anyUnknown = poolReport.state === "unknown" || oracleReport.state === "unknown"; + const overallState: ValidationState = anyMismatch ? "mismatch" : anyUnknown ? "unknown" : "valid"; + + // Preserve existing operator override and blockedAt unless we just cleared mismatch. + const prior = this.lastResult; + const override = prior?.operatorOverride ?? null; + const blockedAt = + overallState !== "valid" + ? (prior?.blockedAt ?? new Date().toISOString()) + : null; + + const result: RevalidationResult = { + state: overallState, + pool: poolReport, + oracle: oracleReport, + blockedAt, + operatorOverride: overallState === "valid" ? null : override, + }; + + this.lastResult = result; + await this.persistState(result); + + if (overallState === "mismatch") { + const affectedNames = [ + ...(poolReport.state === "mismatch" ? poolReport.diff.map((d) => `pool.${d.name}`) : []), + ...(oracleReport.state === "mismatch" ? oracleReport.diff.map((d) => `oracle.${d.name}`) : []), + ]; + this.alerting.emit( + "critical", + "Contract spec mismatch — publication and settlement blocked", + `Differing functions: ${affectedNames.join(", ") || "see diff report"}`, + { poolErrors: poolReport.errors, oracleErrors: oracleReport.errors }, + ); + } else if (overallState === "unknown") { + this.alerting.emit( + "warning", + "Contract spec validation state unknown", + "RPC unreachable — publication and settlement blocked until RPC recovers", + ); + } else if (prior?.state !== "valid") { + this.logger.log("Contract spec revalidation passed — block cleared"); + } + + return result; + } + + getLastResult(): RevalidationResult | null { + return this.lastResult; + } + + // ── Operator override ───────────────────────────────────────────────────── + + async applyOperatorOverride(reason: string, actor: string): Promise { + if (!this.lastResult) { + throw new Error("No validation result yet — cannot apply override"); + } + const override: OperatorOverride = { + reason, + actor, + overriddenAt: new Date().toISOString(), + }; + this.lastResult = { ...this.lastResult, operatorOverride: override }; + await this.persistState(this.lastResult); + this.alerting.emit( + "warning", + "Contract spec block overridden by operator", + `Actor: ${actor}. Reason: ${reason}`, + { actor, reason }, + ); + this.logger.warn(`Operator override applied by ${actor}: ${reason}`); + } + + async clearOperatorOverride(): Promise { + if (!this.lastResult) return; + this.lastResult = { ...this.lastResult, operatorOverride: null }; + await this.persistState(this.lastResult); + } + + // ── Internal ────────────────────────────────────────────────────────────── + + private async checkContract( + name: "pool" | "oracle", + contractId: string, + ): Promise { + if (!contractId) { + return this.emptyReport(name, contractId, "valid"); + } + + try { + const wasm = await this.server.getContractWasmByContractId(contractId); + const wasmHash = Buffer.from(wasm).subarray(0, 8).toString("hex"); + + // Oracle contract shares the same spec structure as the pool contract + // (RefractOracle exposes a subset of the same functions). Re-use the + // same validator — errors will be empty for contracts that don't + // implement functions we check, which is acceptable for now. + const spec = parseContractSpec(wasm); + const errors = validatePoolContractSpec(spec); + + if (errors.length > 0) { + return { + contractId, + contractName: name, + state: "mismatch", + checkedAt: new Date().toISOString(), + errors, + diff: this.errorsToFunctionDiff(errors), + wasmHash, + }; + } + + return { + contractId, + contractName: name, + state: "valid", + checkedAt: new Date().toISOString(), + errors: [], + diff: [], + wasmHash, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + // Distinguish a genuine spec mismatch from RPC/WASM errors — the + // latter are classified as "unknown" so we don't cry wolf about + // upgrades that haven't happened. + if (message.includes("interface mismatch") || message.includes("missing function") || message.includes("expected")) { + return { + contractId, + contractName: name, + state: "mismatch", + checkedAt: new Date().toISOString(), + errors: [message], + diff: this.errorsToFunctionDiff([message]), + wasmHash: null, + }; + } + + this.logger.warn(`RPC unavailable during ${name} contract spec check: ${message}`); + return { + contractId, + contractName: name, + state: "unknown", + checkedAt: new Date().toISOString(), + errors: [`RPC unavailable: ${message}`], + diff: [], + wasmHash: null, + }; + } + } + + /** + * Converts error strings like "buy_policy arguments expected (...) but found (...)" + * into structured FunctionDiff records for the admin API. + */ + private errorsToFunctionDiff(errors: string[]): FunctionDiff[] { + return errors.map((e) => { + const missingMatch = /^missing (function|struct|enum) (\S+)$/.exec(e); + if (missingMatch) { + return { name: missingMatch[2], change: "removed" as const }; + } + const changeMatch = /^(\S+) (?:arguments|result) expected (.+) but found (.+)$/.exec(e); + if (changeMatch) { + return { + name: changeMatch[1], + change: "changed" as const, + expected: changeMatch[2], + actual: changeMatch[3], + }; + } + return { name: e, change: "changed" as const }; + }); + } + + private emptyReport( + name: "pool" | "oracle", + contractId: string, + state: ValidationState, + ): ContractDiffReport { + return { + contractId, + contractName: name, + state, + checkedAt: new Date().toISOString(), + errors: [], + diff: [], + wasmHash: null, + }; + } + + // ── Durable state (Postgres) ────────────────────────────────────────────── + + private async persistState(result: RevalidationResult): Promise { + if (!this.db.isAvailable()) return; + try { + await this.db.query( + `INSERT INTO control_plane_flags (key, value, updated_at) + VALUES ($1, $2, now()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`, + [CONTROL_PLANE_KEY, JSON.stringify(result)], + ); + } catch (err) { + this.logger.error( + "Failed to persist contract spec validation state", + err instanceof Error ? err.message : String(err), + ); + } + } + + private async loadPersistedState(): Promise { + if (!this.db.isAvailable()) return; + try { + const { rows } = await this.db.query<{ value: string }>( + `SELECT value FROM control_plane_flags WHERE key = $1`, + [CONTROL_PLANE_KEY], + ); + if (rows[0]) { + this.lastResult = JSON.parse(rows[0].value) as RevalidationResult; + if (this.lastResult.state !== "valid") { + this.logger.warn( + `Loaded persisted contract spec state: ${this.lastResult.state} — block is active`, + ); + } + } + } catch (err) { + this.logger.warn( + "Could not load persisted contract spec state", + err instanceof Error ? err.message : String(err), + ); + } + } +} diff --git a/src/stellar/ops-contract-spec.controller.ts b/src/stellar/ops-contract-spec.controller.ts new file mode 100644 index 0000000..80bd521 --- /dev/null +++ b/src/stellar/ops-contract-spec.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from "@nestjs/common"; +import { AdminOnly } from "../common/admin-auth"; +import { AdminApiKeyGuard } from "../common/admin-auth"; +import { ContractSpecRevalidator } from "./contract-spec-revalidator"; + +interface OverrideDto { + reason: string; + actor: string; +} + +/** + * Admin endpoints for contract spec revalidation and the diff report. + * + * GET /ops/contracts/diff-report — current diff report (last revalidation) + * POST /ops/contracts/revalidate — trigger an immediate revalidation + * POST /ops/contracts/override — operator override to clear a block + * POST /ops/contracts/override/clear — remove the operator override + */ +@Controller("ops/contracts") +@UseGuards(AdminApiKeyGuard) +export class OpsContractSpecController { + constructor(private readonly revalidator: ContractSpecRevalidator) {} + + @Get("diff-report") + @AdminOnly() + getDiffReport() { + const result = this.revalidator.getLastResult(); + if (!result) { + return { message: "No revalidation has run yet" }; + } + return result; + } + + @Post("revalidate") + @AdminOnly() + @HttpCode(HttpStatus.OK) + async revalidate() { + return this.revalidator.revalidate(); + } + + /** + * Operator override: clears the publication/settlement block with a reason + * and actor recorded for the audit trail. + */ + @Post("override") + @AdminOnly() + @HttpCode(HttpStatus.OK) + async applyOverride(@Body() dto: OverrideDto) { + await this.revalidator.applyOperatorOverride(dto.reason, dto.actor); + return { message: "Override applied. Block cleared until next revalidation.", ...dto }; + } + + @Post("override/clear") + @AdminOnly() + @HttpCode(HttpStatus.OK) + async clearOverride() { + await this.revalidator.clearOperatorOverride(); + return { message: "Operator override removed." }; + } +} diff --git a/src/stellar/stellar.module.ts b/src/stellar/stellar.module.ts index e69de29..eb01a18 100644 --- a/src/stellar/stellar.module.ts +++ b/src/stellar/stellar.module.ts @@ -0,0 +1,20 @@ +import { Global, Module } from "@nestjs/common"; +import { ScheduleModule } from "@nestjs/schedule"; +import { SorobanRpcService } from "./soroban-rpc.service"; +import { ContractSpecRevalidator } from "./contract-spec-revalidator"; +import { OpsContractSpecController } from "./ops-contract-spec.controller"; +import { AlertingModule } from "../alerting/alerting.module"; +import { DbModule } from "../db/db.module"; + +/** + * Global so Policy/Pool/Claim/Tx modules need no import churn — same + * precedent as ConfigModule.forRoot({ isGlobal: true }). + */ +@Global() +@Module({ + imports: [ScheduleModule, AlertingModule, DbModule], + providers: [SorobanRpcService, ContractSpecRevalidator], + controllers: [OpsContractSpecController], + exports: [SorobanRpcService, ContractSpecRevalidator], +}) +export class StellarModule {}