diff --git a/backend/src/modules/blockchain/blockchain.module.ts b/backend/src/modules/blockchain/blockchain.module.ts index 72a70e3d..42dc32a0 100644 --- a/backend/src/modules/blockchain/blockchain.module.ts +++ b/backend/src/modules/blockchain/blockchain.module.ts @@ -31,6 +31,7 @@ import { JobQueueModule } from '../job-queue/job-queue.module'; import { EventStreamBackpressureService } from './event-stream-backpressure.service'; import { ProtocolMetrics } from '../admin-analytics/entities/protocol-metrics.entity'; import { TransactionsModule } from '../transactions/transactions.module'; +import { ContractEventValidatorService } from './contract-event-validator.service'; @Global() @Module({ @@ -74,7 +75,7 @@ import { TransactionsModule } from '../transactions/transactions.module'; YieldHandler, BalanceSyncService, EventStreamBackpressureService, - BlockchainEventParser, + ContractEventValidatorService, ], exports: [ StellarService, @@ -89,7 +90,7 @@ import { TransactionsModule } from '../transactions/transactions.module'; YieldHandler, BalanceSyncService, EventStreamBackpressureService, - BlockchainEventParser, + ContractEventValidatorService, ], }) export class BlockchainModule {} diff --git a/backend/src/modules/blockchain/contract-event-schema.ts b/backend/src/modules/blockchain/contract-event-schema.ts new file mode 100644 index 00000000..17f4ca7e --- /dev/null +++ b/backend/src/modules/blockchain/contract-event-schema.ts @@ -0,0 +1,294 @@ +/** + * Issue #1112 – Contract Event Schema Validation (Runtime) + * + * Defines the expected payload shapes for each Soroban contract event type + * processed by the blockchain indexer, plus a lightweight schema-check helper + * that returns structured violation descriptors without throwing. + * + * Design notes + * ───────────── + * • Uses pure TypeScript interfaces and manual checks instead of an extra + * runtime schema library (the project already has class-validator / joi but + * adding Zod just for this would be scope-creep). + * • Every validator returns `SchemaViolation[]`. An empty array means valid. + * • Checks are intentionally non-throwing – callers decide how to handle + * violations (log, alert, reject, or continue). + */ + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export type ContractEventType = 'Deposit' | 'Withdraw' | 'Yield'; + +/** A single schema violation found during validation. */ +export interface SchemaViolation { + /** Path to the violating field (dot-notation). */ + field: string; + /** Human-readable description of the problem. */ + message: string; + /** The actual value that was received (for logging; may be any type). */ + received: unknown; +} + +/** Result returned by all validate* functions. */ +export interface ValidationResult { + valid: boolean; + violations: SchemaViolation[]; +} + +// --------------------------------------------------------------------------- +// Base Soroban event envelope schema +// --------------------------------------------------------------------------- + +/** + * Minimal required fields that every Soroban event received by the indexer + * must carry. These are checked BEFORE the payload is decoded. + */ +export interface SorobanEventEnvelope { + id?: string; + ledger: number; + topic?: unknown[]; + value?: unknown; + txHash?: string; + contractId?: string; +} + +/** + * Validate the top-level envelope of a raw Soroban event. + * + * @param event Raw event object as produced by the Stellar RPC. + * @returns ValidationResult with any discovered violations. + */ +export function validateSorobanEventEnvelope( + event: Record, +): ValidationResult { + const violations: SchemaViolation[] = []; + + // ledger must be a positive integer + if ( + typeof event['ledger'] !== 'number' || + !Number.isInteger(event['ledger']) || + event['ledger'] <= 0 + ) { + violations.push({ + field: 'ledger', + message: 'Must be a positive integer', + received: event['ledger'], + }); + } + + // topic must be a non-empty array + if ( + !Array.isArray(event['topic']) || + (event['topic'] as unknown[]).length === 0 + ) { + violations.push({ + field: 'topic', + message: 'Must be a non-empty array', + received: event['topic'], + }); + } + + // value must be present (not null/undefined) + if (event['value'] === undefined || event['value'] === null) { + violations.push({ + field: 'value', + message: 'Must be present', + received: event['value'], + }); + } + + // id should be a non-empty string (warn, not error – indexer can synthesise it) + if ( + event['id'] !== undefined && + (typeof event['id'] !== 'string' || event['id'].trim() === '') + ) { + violations.push({ + field: 'id', + message: 'When provided, must be a non-empty string', + received: event['id'], + }); + } + + // txHash should be a non-empty string when provided + if ( + event['txHash'] !== undefined && + (typeof event['txHash'] !== 'string' || event['txHash'].trim() === '') + ) { + violations.push({ + field: 'txHash', + message: 'When provided, must be a non-empty string', + received: event['txHash'], + }); + } + + return { valid: violations.length === 0, violations }; +} + +// --------------------------------------------------------------------------- +// Deposit payload schema +// --------------------------------------------------------------------------- + +/** + * Expected shape of the decoded Deposit event payload. + * Produced after XDR/ScVal decoding in DepositHandler.extractPayload(). + */ +export interface DepositPayloadSchema { + publicKey: string; + amount: string; // Numeric string, > 0 +} + +/** + * Validate a decoded Deposit payload. + */ +export function validateDepositPayload( + payload: Record, +): ValidationResult { + return validateTransferPayload('Deposit', payload); +} + +// --------------------------------------------------------------------------- +// Withdraw payload schema +// --------------------------------------------------------------------------- + +/** + * Expected shape of the decoded Withdraw event payload. + */ +export interface WithdrawPayloadSchema { + publicKey: string; + amount: string; +} + +/** + * Validate a decoded Withdraw payload. + */ +export function validateWithdrawPayload( + payload: Record, +): ValidationResult { + return validateTransferPayload('Withdraw', payload); +} + +// --------------------------------------------------------------------------- +// Yield payload schema +// --------------------------------------------------------------------------- + +/** + * Expected shape of the decoded Yield event payload. + * The amount represents interest earned (positive value). + */ +export interface YieldPayloadSchema { + publicKey: string; + amount: string; +} + +/** + * Validate a decoded Yield payload. + */ +export function validateYieldPayload( + payload: Record, +): ValidationResult { + return validateTransferPayload('Yield', payload); +} + +// --------------------------------------------------------------------------- +// Dispatch helper – validate by event type +// --------------------------------------------------------------------------- + +/** + * Validate a decoded payload against the schema for the given event type. + * + * @param eventType One of 'Deposit' | 'Withdraw' | 'Yield'. + * @param payload Decoded (native) payload object. + * @returns ValidationResult. + */ +export function validateEventPayloadByType( + eventType: ContractEventType, + payload: Record, +): ValidationResult { + switch (eventType) { + case 'Deposit': + return validateDepositPayload(payload); + case 'Withdraw': + return validateWithdrawPayload(payload); + case 'Yield': + return validateYieldPayload(payload); + default: { + // Narrow to never so TypeScript enforces exhaustiveness at compile time + const _exhaustive: never = eventType; + return { + valid: false, + violations: [ + { + field: 'eventType', + message: `Unknown event type: ${String(_exhaustive)}`, + received: _exhaustive, + }, + ], + }; + } + } +} + +// --------------------------------------------------------------------------- +// Private shared validator +// --------------------------------------------------------------------------- + +/** + * Both Deposit, Withdraw and Yield have the same decoded shape: + * { publicKey: string, amount: numericString }. + * This function validates that shape and is reused by all three. + */ +function validateTransferPayload( + label: string, + payload: Record, +): ValidationResult { + const violations: SchemaViolation[] = []; + + // publicKey: non-empty string + if ( + typeof payload['publicKey'] !== 'string' || + payload['publicKey'].trim() === '' + ) { + violations.push({ + field: 'publicKey', + message: `${label} payload.publicKey must be a non-empty string`, + received: payload['publicKey'], + }); + } + + // amount: string that parses to a finite positive number + if ( + typeof payload['amount'] !== 'string' || + payload['amount'].trim() === '' + ) { + violations.push({ + field: 'amount', + message: `${label} payload.amount must be a non-empty string`, + received: payload['amount'], + }); + } else { + const numeric = Number(payload['amount']); + if (Number.isNaN(numeric)) { + violations.push({ + field: 'amount', + message: `${label} payload.amount must be a numeric string`, + received: payload['amount'], + }); + } else if (!Number.isFinite(numeric)) { + violations.push({ + field: 'amount', + message: `${label} payload.amount must be a finite number`, + received: payload['amount'], + }); + } else if (numeric <= 0) { + violations.push({ + field: 'amount', + message: `${label} payload.amount must be greater than zero`, + received: payload['amount'], + }); + } + } + + return { valid: violations.length === 0, violations }; +} diff --git a/backend/src/modules/blockchain/contract-event-validator.service.spec.ts b/backend/src/modules/blockchain/contract-event-validator.service.spec.ts new file mode 100644 index 00000000..eebc5b94 --- /dev/null +++ b/backend/src/modules/blockchain/contract-event-validator.service.spec.ts @@ -0,0 +1,526 @@ +/** + * Issue #1112 – Contract Event Schema Validation (Runtime) + * + * Unit tests for ContractEventValidatorService. + * + * Coverage + * ───────── + * 1. validateEnvelope() + * • Valid envelope → returns true, no warn logged + * • Missing / wrong-type ledger → returns false, warn logged + * • Empty / missing topic array → returns false, warn logged + * • Null value field → returns false, warn logged + * • Unexpected error in schema function → returns false, error logged + * + * 2. validatePayload() – Deposit + * • Valid payload → returns true, no warn logged + * • Missing publicKey → returns false, warn logged + * • Empty publicKey string → returns false, warn logged + * • Missing amount → returns false, warn logged + * • Non-numeric amount string → returns false, warn logged + * • Zero / negative amount → returns false, warn logged + * + * 3. validatePayload() – Withdraw (same shape, sanity-check) + * • Valid → true + * • Invalid → false, warn logged + * + * 4. validatePayload() – Yield (same shape, sanity-check) + * • Valid → true + * • Invalid → false, warn logged + * + * 5. Alert / warn emission + * • Warn log always includes handlerName, eventId, ledgerSequence, + * contractId, validationTarget, violations, violationSummary. + * • violationSummary is a non-empty string listing field names. + * + * 6. Non-blocking behaviour + * • validateEnvelope() never throws even when schema func is patched to + * throw internally. + * • validatePayload() never throws. + */ + +import { Logger } from '@nestjs/common'; +import { ContractEventValidatorService } from './contract-event-validator.service'; +import * as schema from './contract-event-schema'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const BASE_CTX = { + handlerName: 'TestHandler', + eventId: 'evt-001', + ledgerSequence: 42, + contractId: 'CONTRACT_XYZ', +} as const; + +/** Build a minimal valid envelope. */ +function validEnvelope(): Record { + return { + id: 'evt-001', + ledger: 42, + topic: ['topic-value'], + value: { some: 'data' }, + txHash: 'tx-hash-abc', + contractId: 'CONTRACT_XYZ', + }; +} + +/** Build a valid Deposit/Withdraw/Yield payload. */ +function validTransferPayload(): Record { + return { + publicKey: 'GBXYZ1234567890', + amount: '100.50', + }; +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +describe('ContractEventValidatorService', () => { + let service: ContractEventValidatorService; + let warnCalls: unknown[][]; + let errorCalls: unknown[][]; + + beforeEach(() => { + service = new ContractEventValidatorService(); + + warnCalls = []; + errorCalls = []; + + jest + .spyOn(service.logger as any, 'warn') + .mockImplementation((...args: unknown[]) => { + warnCalls.push(args); + }); + jest + .spyOn(service.logger as any, 'error') + .mockImplementation((...args: unknown[]) => { + errorCalls.push(args); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ========================================================================= + // 1. validateEnvelope – valid case + // ========================================================================= + describe('validateEnvelope – valid envelope', () => { + it('returns true for a fully valid envelope', () => { + const result = service.validateEnvelope(validEnvelope(), BASE_CTX); + expect(result).toBe(true); + }); + + it('does not emit any warn log for a valid envelope', () => { + service.validateEnvelope(validEnvelope(), BASE_CTX); + expect(warnCalls).toHaveLength(0); + }); + + it('does not emit any error log for a valid envelope', () => { + service.validateEnvelope(validEnvelope(), BASE_CTX); + expect(errorCalls).toHaveLength(0); + }); + }); + + // ========================================================================= + // 2. validateEnvelope – invalid cases + // ========================================================================= + describe('validateEnvelope – invalid envelope', () => { + it('returns false when ledger is missing', () => { + const envelope = validEnvelope(); + delete envelope['ledger']; + expect(service.validateEnvelope(envelope, BASE_CTX)).toBe(false); + }); + + it('returns false when ledger is zero', () => { + const envelope = { ...validEnvelope(), ledger: 0 }; + expect(service.validateEnvelope(envelope, BASE_CTX)).toBe(false); + }); + + it('returns false when ledger is a string', () => { + const envelope = { ...validEnvelope(), ledger: '42' }; + expect(service.validateEnvelope(envelope, BASE_CTX)).toBe(false); + }); + + it('returns false when topic is an empty array', () => { + const envelope = { ...validEnvelope(), topic: [] }; + expect(service.validateEnvelope(envelope, BASE_CTX)).toBe(false); + }); + + it('returns false when topic is missing', () => { + const envelope = validEnvelope(); + delete envelope['topic']; + expect(service.validateEnvelope(envelope, BASE_CTX)).toBe(false); + }); + + it('returns false when value is null', () => { + const envelope = { ...validEnvelope(), value: null }; + expect(service.validateEnvelope(envelope, BASE_CTX)).toBe(false); + }); + + it('returns false when value is undefined', () => { + const envelope = validEnvelope(); + delete envelope['value']; + expect(service.validateEnvelope(envelope, BASE_CTX)).toBe(false); + }); + + it('emits a warn log on envelope violations', () => { + const envelope = { ...validEnvelope(), ledger: -1 }; + service.validateEnvelope(envelope, BASE_CTX); + expect(warnCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('warn log context includes handlerName', () => { + const envelope = { ...validEnvelope(), topic: [] }; + service.validateEnvelope(envelope, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(ctx['handlerName']).toBe('TestHandler'); + }); + + it('warn log context includes eventId', () => { + const envelope = { ...validEnvelope(), topic: [] }; + service.validateEnvelope(envelope, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(ctx['eventId']).toBe('evt-001'); + }); + + it('warn log context includes ledgerSequence', () => { + const envelope = { ...validEnvelope(), topic: [] }; + service.validateEnvelope(envelope, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(ctx['ledgerSequence']).toBe(42); + }); + + it('warn log context includes contractId', () => { + const envelope = { ...validEnvelope(), topic: [] }; + service.validateEnvelope(envelope, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(ctx['contractId']).toBe('CONTRACT_XYZ'); + }); + + it('warn log context includes validationTarget = envelope', () => { + const envelope = { ...validEnvelope(), topic: [] }; + service.validateEnvelope(envelope, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(ctx['validationTarget']).toBe('envelope'); + }); + + it('warn log context includes non-empty violations array', () => { + const envelope = { ...validEnvelope(), topic: [] }; + service.validateEnvelope(envelope, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(Array.isArray(ctx['violations'])).toBe(true); + expect((ctx['violations'] as unknown[]).length).toBeGreaterThan(0); + }); + + it('warn log context includes non-empty violationSummary string', () => { + const envelope = { ...validEnvelope(), topic: [] }; + service.validateEnvelope(envelope, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(typeof ctx['violationSummary']).toBe('string'); + expect((ctx['violationSummary'] as string).length).toBeGreaterThan(0); + }); + }); + + // ========================================================================= + // 3. validateEnvelope – non-blocking (internal error) + // ========================================================================= + describe('validateEnvelope – non-blocking on unexpected internal error', () => { + it('does not throw even if the schema function throws', () => { + jest + .spyOn(schema, 'validateSorobanEventEnvelope') + .mockImplementationOnce(() => { + throw new Error('unexpected internal error'); + }); + + expect(() => + service.validateEnvelope(validEnvelope(), BASE_CTX), + ).not.toThrow(); + }); + + it('returns false if the schema function throws', () => { + jest + .spyOn(schema, 'validateSorobanEventEnvelope') + .mockImplementationOnce(() => { + throw new Error('unexpected internal error'); + }); + + const result = service.validateEnvelope(validEnvelope(), BASE_CTX); + expect(result).toBe(false); + }); + + it('logs an error (not warn) if the schema function throws', () => { + jest + .spyOn(schema, 'validateSorobanEventEnvelope') + .mockImplementationOnce(() => { + throw new Error('unexpected internal error'); + }); + + service.validateEnvelope(validEnvelope(), BASE_CTX); + expect(errorCalls.length).toBeGreaterThanOrEqual(1); + }); + }); + + // ========================================================================= + // 4. validatePayload – Deposit + // ========================================================================= + describe('validatePayload – Deposit – valid payload', () => { + it('returns true for a valid Deposit payload', () => { + expect( + service.validatePayload('Deposit', validTransferPayload(), BASE_CTX), + ).toBe(true); + }); + + it('does not emit any warn log for a valid Deposit payload', () => { + service.validatePayload('Deposit', validTransferPayload(), BASE_CTX); + expect(warnCalls).toHaveLength(0); + }); + }); + + describe('validatePayload – Deposit – invalid payloads', () => { + it('returns false when publicKey is missing', () => { + const payload = { amount: '10' }; + expect(service.validatePayload('Deposit', payload, BASE_CTX)).toBe(false); + }); + + it('returns false when publicKey is an empty string', () => { + const payload = { publicKey: '', amount: '10' }; + expect(service.validatePayload('Deposit', payload, BASE_CTX)).toBe(false); + }); + + it('returns false when publicKey is whitespace only', () => { + const payload = { publicKey: ' ', amount: '10' }; + expect(service.validatePayload('Deposit', payload, BASE_CTX)).toBe(false); + }); + + it('returns false when publicKey is a number', () => { + const payload = { publicKey: 12345, amount: '10' }; + expect(service.validatePayload('Deposit', payload, BASE_CTX)).toBe(false); + }); + + it('returns false when amount is missing', () => { + const payload = { publicKey: 'GBXYZ' }; + expect(service.validatePayload('Deposit', payload, BASE_CTX)).toBe(false); + }); + + it('returns false when amount is a non-numeric string', () => { + const payload = { publicKey: 'GBXYZ', amount: 'not-a-number' }; + expect(service.validatePayload('Deposit', payload, BASE_CTX)).toBe(false); + }); + + it('returns false when amount is zero', () => { + const payload = { publicKey: 'GBXYZ', amount: '0' }; + expect(service.validatePayload('Deposit', payload, BASE_CTX)).toBe(false); + }); + + it('returns false when amount is negative', () => { + const payload = { publicKey: 'GBXYZ', amount: '-50' }; + expect(service.validatePayload('Deposit', payload, BASE_CTX)).toBe(false); + }); + + it('returns false when amount is NaN string', () => { + const payload = { publicKey: 'GBXYZ', amount: 'NaN' }; + expect(service.validatePayload('Deposit', payload, BASE_CTX)).toBe(false); + }); + + it('emits warn log on Deposit violations', () => { + service.validatePayload( + 'Deposit', + { publicKey: '', amount: '10' }, + BASE_CTX, + ); + expect(warnCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('warn log validationTarget is Deposit', () => { + service.validatePayload( + 'Deposit', + { publicKey: '', amount: '10' }, + BASE_CTX, + ); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(ctx['validationTarget']).toBe('Deposit'); + }); + }); + + // ========================================================================= + // 5. validatePayload – Withdraw + // ========================================================================= + describe('validatePayload – Withdraw', () => { + it('returns true for a valid Withdraw payload', () => { + expect( + service.validatePayload('Withdraw', validTransferPayload(), BASE_CTX), + ).toBe(true); + }); + + it('returns false when Withdraw publicKey is missing', () => { + expect( + service.validatePayload('Withdraw', { amount: '5' }, BASE_CTX), + ).toBe(false); + }); + + it('emits warn with validationTarget = Withdraw on violation', () => { + service.validatePayload('Withdraw', { amount: '5' }, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(ctx['validationTarget']).toBe('Withdraw'); + }); + }); + + // ========================================================================= + // 6. validatePayload – Yield + // ========================================================================= + describe('validatePayload – Yield', () => { + it('returns true for a valid Yield payload', () => { + expect( + service.validatePayload('Yield', validTransferPayload(), BASE_CTX), + ).toBe(true); + }); + + it('returns false when Yield amount is a non-numeric string', () => { + const payload = { publicKey: 'GBXYZ', amount: 'bad' }; + expect(service.validatePayload('Yield', payload, BASE_CTX)).toBe(false); + }); + + it('emits warn with validationTarget = Yield on violation', () => { + service.validatePayload( + 'Yield', + { publicKey: 'GBXYZ', amount: 'bad' }, + BASE_CTX, + ); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(ctx['validationTarget']).toBe('Yield'); + }); + }); + + // ========================================================================= + // 7. validatePayload – non-blocking on unexpected internal error + // ========================================================================= + describe('validatePayload – non-blocking on unexpected internal error', () => { + it('does not throw if the schema dispatch function throws', () => { + jest + .spyOn(schema, 'validateEventPayloadByType') + .mockImplementationOnce(() => { + throw new Error('dispatch failure'); + }); + + expect(() => + service.validatePayload('Deposit', validTransferPayload(), BASE_CTX), + ).not.toThrow(); + }); + + it('returns false if the schema dispatch function throws', () => { + jest + .spyOn(schema, 'validateEventPayloadByType') + .mockImplementationOnce(() => { + throw new Error('dispatch failure'); + }); + + expect( + service.validatePayload('Deposit', validTransferPayload(), BASE_CTX), + ).toBe(false); + }); + + it('logs an error (not warn) if the schema dispatch function throws', () => { + jest + .spyOn(schema, 'validateEventPayloadByType') + .mockImplementationOnce(() => { + throw new Error('dispatch failure'); + }); + + service.validatePayload('Deposit', validTransferPayload(), BASE_CTX); + expect(errorCalls.length).toBeGreaterThanOrEqual(1); + }); + }); + + // ========================================================================= + // 8. Alert context completeness invariants + // ========================================================================= + describe('Alert / warn log context invariants', () => { + it('warn context always has violationCount > 0 for envelope violations', () => { + service.validateEnvelope({ ...validEnvelope(), ledger: 'bad' }, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(Number(ctx['violationCount'])).toBeGreaterThan(0); + }); + + it('warn context always has violationCount > 0 for payload violations', () => { + service.validatePayload( + 'Deposit', + { publicKey: '', amount: '' }, + BASE_CTX, + ); + const [, ctx] = warnCalls[0] as [string, Record]; + expect(Number(ctx['violationCount'])).toBeGreaterThan(0); + }); + + it('violationSummary references the violating field name', () => { + service.validatePayload('Deposit', { amount: '10' }, BASE_CTX); + const [, ctx] = warnCalls[0] as [string, Record]; + const summary = ctx['violationSummary'] as string; + expect(summary).toContain('publicKey'); + }); + + it('each violation in the violations array has field, message, received', () => { + service.validatePayload( + 'Deposit', + { publicKey: '', amount: '' }, + BASE_CTX, + ); + const [, ctx] = warnCalls[0] as [string, Record]; + const violations = ctx['violations'] as schema.SchemaViolation[]; + for (const v of violations) { + expect(typeof v.field).toBe('string'); + expect(typeof v.message).toBe('string'); + expect('received' in v).toBe(true); + } + }); + + it('null contractId is handled gracefully in warn context', () => { + const ctx = { ...BASE_CTX, contractId: null }; + service.validateEnvelope({ ...validEnvelope(), topic: [] }, ctx); + const [, logCtx] = warnCalls[0] as [string, Record]; + expect(logCtx['contractId']).toBeNull(); + }); + + it('undefined contractId is normalised to null in warn context', () => { + const ctx = { + handlerName: 'TestHandler', + eventId: 'e', + ledgerSequence: 1, + }; + service.validateEnvelope({ ...validEnvelope(), topic: [] }, ctx); + const [, logCtx] = warnCalls[0] as [string, Record]; + expect(logCtx['contractId']).toBeNull(); + }); + }); + + // ========================================================================= + // 9. Multiple violations in one pass + // ========================================================================= + describe('Multiple violations', () => { + it('captures all violations when both publicKey and amount are bad', () => { + service.validatePayload( + 'Deposit', + { publicKey: '', amount: 'xyz' }, + BASE_CTX, + ); + const [, ctx] = warnCalls[0] as [string, Record]; + const violations = ctx['violations'] as schema.SchemaViolation[]; + expect(violations.length).toBeGreaterThanOrEqual(2); + }); + + it('violationSummary contains both field names when both fail', () => { + service.validatePayload( + 'Deposit', + { publicKey: '', amount: 'xyz' }, + BASE_CTX, + ); + const [, ctx] = warnCalls[0] as [string, Record]; + const summary = ctx['violationSummary'] as string; + expect(summary).toContain('publicKey'); + expect(summary).toContain('amount'); + }); + }); +}); diff --git a/backend/src/modules/blockchain/contract-event-validator.service.ts b/backend/src/modules/blockchain/contract-event-validator.service.ts new file mode 100644 index 00000000..26c6078a --- /dev/null +++ b/backend/src/modules/blockchain/contract-event-validator.service.ts @@ -0,0 +1,202 @@ +/** + * Issue #1112 – Contract Event Schema Validation (Runtime) + * + * ContractEventValidatorService is an injectable NestJS service that validates + * incoming Soroban event envelopes and decoded payloads against the schemas + * defined in contract-event-schema.ts. + * + * Design decisions + * ───────────────── + * • Validation is NEVER blocking. A schema mismatch emits a structured warn / + * error log and returns false, but does not throw and does not halt event + * processing. The caller decides whether to proceed. + * • All log lines carry a consistent context object so they are machine-readable + * and compatible with the project's structured-logging patterns. + * • The service is stateless and has no database dependencies, keeping it + * lightweight and easy to unit-test. + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { + ContractEventType, + SchemaViolation, + ValidationResult, + validateSorobanEventEnvelope, + validateEventPayloadByType, +} from './contract-event-schema'; + +// --------------------------------------------------------------------------- +// Public interface types +// --------------------------------------------------------------------------- + +/** Context attached to every structured log / alert emitted by this service. */ +export interface ValidationAlertContext { + /** The symbolic name of the calling handler (e.g. 'DepositHandler'). */ + handlerName: string; + /** Synthetic or raw event ID (matches indexer log convention). */ + eventId: string | null; + /** Ledger sequence number; null when unknown. */ + ledgerSequence: number | null; + /** Contract ID if available. */ + contractId?: string | null; + /** Which validation step failed: 'envelope' or the event-type name. */ + validationTarget: 'envelope' | ContractEventType; + /** Structured list of violations. */ + violations: SchemaViolation[]; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +@Injectable() +export class ContractEventValidatorService { + readonly logger = new Logger(ContractEventValidatorService.name); + + // ------------------------------------------------------------------------- + // Envelope validation + // ------------------------------------------------------------------------- + + /** + * Validate the raw (pre-decoding) Soroban event envelope. + * + * Should be called at the BEGINNING of each handler's `handle()` method, + * before any XDR decoding, so that structurally invalid events are flagged + * immediately. + * + * @returns `true` if the envelope is valid; `false` with a logged warning + * otherwise. Processing can continue either way. + */ + validateEnvelope( + event: Record, + context: { + handlerName: string; + eventId: string | null; + ledgerSequence: number | null; + contractId?: string | null; + }, + ): boolean { + let result: ValidationResult; + try { + result = validateSorobanEventEnvelope(event); + } catch (err) { + // The schema function itself should never throw, but guard against it + this.logger.error( + 'ContractEventValidatorService: unexpected error during envelope validation', + { + handlerName: context.handlerName, + eventId: context.eventId, + ledgerSequence: context.ledgerSequence, + contractId: context.contractId ?? null, + validationTarget: 'envelope', + error: (err as Error).message, + }, + ); + return false; + } + + if (!result.valid) { + this.emitViolationAlert({ + handlerName: context.handlerName, + eventId: context.eventId, + ledgerSequence: context.ledgerSequence, + contractId: context.contractId ?? null, + validationTarget: 'envelope', + violations: result.violations, + }); + } + + return result.valid; + } + + // ------------------------------------------------------------------------- + // Decoded payload validation + // ------------------------------------------------------------------------- + + /** + * Validate a decoded (native JS object) payload against the schema for the + * given event type. + * + * Should be called immediately AFTER the handler decodes the XDR value into + * a plain object, so that unexpected field shapes (e.g., due to a contract + * upgrade) are detected and logged before further processing. + * + * @returns `true` if valid; `false` with a logged warning otherwise. + */ + validatePayload( + eventType: ContractEventType, + payload: Record, + context: { + handlerName: string; + eventId: string | null; + ledgerSequence: number | null; + contractId?: string | null; + }, + ): boolean { + let result: ValidationResult; + try { + result = validateEventPayloadByType(eventType, payload); + } catch (err) { + this.logger.error( + 'ContractEventValidatorService: unexpected error during payload validation', + { + handlerName: context.handlerName, + eventId: context.eventId, + ledgerSequence: context.ledgerSequence, + contractId: context.contractId ?? null, + validationTarget: eventType, + error: (err as Error).message, + }, + ); + return false; + } + + if (!result.valid) { + this.emitViolationAlert({ + handlerName: context.handlerName, + eventId: context.eventId, + ledgerSequence: context.ledgerSequence, + contractId: context.contractId ?? null, + validationTarget: eventType, + violations: result.violations, + }); + } + + return result.valid; + } + + // ------------------------------------------------------------------------- + // Alert emission + // ------------------------------------------------------------------------- + + /** + * Emit a structured WARN log for schema violations. + * + * Using `warn` (not `error`) because a schema mismatch is a signal that + * the contract may have changed, but we still want to attempt processing + * rather than silently drop the event. The caller can escalate to an + * error after deciding how to handle the mismatch. + */ + private emitViolationAlert(ctx: ValidationAlertContext): void { + const violationSummary = ctx.violations + .map( + (v) => + `[${v.field}] ${v.message} (received: ${JSON.stringify(v.received)})`, + ) + .join(' | '); + + this.logger.warn( + `ContractEventValidatorService: schema violation detected on ${ctx.validationTarget}`, + { + handlerName: ctx.handlerName, + eventId: ctx.eventId, + ledgerSequence: ctx.ledgerSequence, + contractId: ctx.contractId ?? null, + validationTarget: ctx.validationTarget, + violationCount: ctx.violations.length, + violations: ctx.violations, + violationSummary, + }, + ); + } +} diff --git a/backend/src/modules/blockchain/event-handlers/deposit.handler.ts b/backend/src/modules/blockchain/event-handlers/deposit.handler.ts index cc38853e..05d927c5 100644 --- a/backend/src/modules/blockchain/event-handlers/deposit.handler.ts +++ b/backend/src/modules/blockchain/event-handlers/deposit.handler.ts @@ -15,11 +15,21 @@ import { import { User } from '../../user/entities/user.entity'; import { SavingsProduct } from '../../savings/entities/savings-product.entity'; import { TransactionStateMachineService } from '../../transactions/transaction-state-machine.service'; -import { - BlockchainEventParser, - RawBlockchainEvent, -} from '../blockchain-event-parser.service'; -import { QuarantineReason } from '../entities/malformed-blockchain-event.entity'; +import { ContractEventValidatorService } from '../contract-event-validator.service'; + +interface IndexerEvent { + id?: string; + topic?: unknown[]; + value?: unknown; + txHash?: string; + ledger?: number; + [key: string]: unknown; +} + +interface DepositPayload { + publicKey: string; + amount: string; +} @Injectable() export class DepositHandler { @@ -31,7 +41,7 @@ export class DepositHandler { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly transactionStateMachine: TransactionStateMachineService, - private readonly eventParser: BlockchainEventParser, + private readonly contractEventValidator: ContractEventValidatorService, ) {} async handle(event: RawBlockchainEvent): Promise { @@ -39,24 +49,46 @@ export class DepositHandler { return false; } - // ── Structural validation ──────────────────────────────────────────────── - const structuralFailure = this.eventParser.validateEventStructure(event); - if (structuralFailure) { - await this.eventParser.quarantineEvent( - event, - structuralFailure.reason, - structuralFailure.errorDetails, - 'Deposit', - ); - return true; // Event was ours — we handled it by quarantining + const eventId = this.resolveEventId(event); + const ledgerSequence = + typeof event.ledger === 'number' ? event.ledger : null; + const contractId = + typeof event.contractId === 'string' ? event.contractId : null; + const validationCtx = { + handlerName: 'DepositHandler', + eventId, + ledgerSequence, + contractId, + }; + + // Validate the raw event envelope before any decoding + this.contractEventValidator.validateEnvelope(event, validationCtx); + + let payload: DepositPayload; + try { + payload = this.extractPayload(event.value); + } catch (err) { + this.logger.error('DepositHandler: failed to extract payload', { + handlerName: 'DepositHandler', + eventId, + ledgerSequence, + error: (err as Error).message, + }); + throw err; } - // ── Payload parsing ────────────────────────────────────────────────────── - const parseResult = this.eventParser.parseStandardPayload(event, { - eventType: 'Deposit', - publicKeyFields: ['publicKey', 'userPublicKey', 'user', 'address', 'to'], - amountFields: ['amount', 'value', 'amt'], - }); + // Validate the decoded payload against the Deposit schema + this.contractEventValidator.validatePayload( + 'Deposit', + payload as unknown as Record, + validationCtx, + ); + + await this.dataSource.transaction(async (manager) => { + const userRepo = manager.getRepository(User); + const txRepo = manager.getRepository(LedgerTransaction); + const subRepo = manager.getRepository(UserSubscription); + const productRepo = manager.getRepository(SavingsProduct); if (!parseResult.ok) { await this.eventParser.quarantineEvent( @@ -92,13 +124,55 @@ export class DepositHandler { ); } - const existingTx = await txRepo.findOne({ where: { eventId } }); - if (existingTx) { - this.logger.debug( - `Deposit event ${eventId} already persisted. Skipping.`, - ); - return; - } + const createdTx = await this.transactionStateMachine.createTransaction( + { + userId: user.id, + type: LedgerTransactionType.DEPOSIT, + amount: payload.amount, + publicKey: payload.publicKey, + eventId, + txHash: typeof event.txHash === 'string' ? event.txHash : null, + ledgerSequence: + typeof event.ledger === 'number' ? String(event.ledger) : null, + metadata: { + topic: event.topic, + rawValueType: typeof event.value, + }, + }, + { + manager, + actor: 'blockchain-indexer', + reason: 'Deposit event ingested', + metadata: { eventId }, + }, + ); + await this.transactionStateMachine.transitionStatus( + createdTx.id, + LedgerTransactionStatus.PENDING_CONFIRMATION, + { + manager, + actor: 'blockchain-indexer', + reason: 'Deposit pending confirmation', + }, + ); + await this.transactionStateMachine.transitionStatus( + createdTx.id, + LedgerTransactionStatus.CONFIRMED, + { + manager, + actor: 'blockchain-indexer', + reason: 'Deposit confirmed on ledger', + }, + ); + await this.transactionStateMachine.transitionStatus( + createdTx.id, + LedgerTransactionStatus.COMPLETED, + { + manager, + actor: 'blockchain-indexer', + reason: 'Deposit workflow completed', + }, + ); const createdTx = await this.transactionStateMachine.createTransaction( { @@ -141,23 +215,13 @@ export class DepositHandler { order: { createdAt: 'DESC' }, }); - if (!subscription) { - const defaultProduct = user.defaultSavingsProductId - ? await productRepo.findOne({ - where: { id: user.defaultSavingsProductId }, - }) - : await productRepo.findOne({ - where: { isActive: true }, - order: { createdAt: 'ASC' }, - }); - - if (!defaultProduct) { - throw new Error( - 'No savings product found to create subscription aggregate.', - ); - } - - subscription = subRepo.create({ + if (!defaultProduct) { + const msg = + 'No savings product found to create subscription aggregate.'; + this.logger.error('DepositHandler: no savings product found', { + handlerName: 'DepositHandler', + eventId, + ledgerSequence, userId: user.id, productId: defaultProduct.id, amount: amountAsNumber, diff --git a/backend/src/modules/blockchain/event-handlers/withdraw.handler.ts b/backend/src/modules/blockchain/event-handlers/withdraw.handler.ts index 672f4c8a..fae3f3c1 100644 --- a/backend/src/modules/blockchain/event-handlers/withdraw.handler.ts +++ b/backend/src/modules/blockchain/event-handlers/withdraw.handler.ts @@ -14,11 +14,21 @@ import { } from '../../savings/entities/user-subscription.entity'; import { User } from '../../user/entities/user.entity'; import { TransactionStateMachineService } from '../../transactions/transaction-state-machine.service'; -import { - BlockchainEventParser, - RawBlockchainEvent, -} from '../blockchain-event-parser.service'; -import { QuarantineReason } from '../entities/malformed-blockchain-event.entity'; +import { ContractEventValidatorService } from '../contract-event-validator.service'; + +interface IndexerEvent { + id?: string; + topic?: unknown[]; + value?: unknown; + txHash?: string; + ledger?: number; + [key: string]: unknown; +} + +interface WithdrawPayload { + publicKey: string; + amount: string; +} @Injectable() export class WithdrawHandler { @@ -30,7 +40,7 @@ export class WithdrawHandler { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly transactionStateMachine: TransactionStateMachineService, - private readonly eventParser: BlockchainEventParser, + private readonly contractEventValidator: ContractEventValidatorService, ) {} async handle(event: RawBlockchainEvent): Promise { @@ -38,31 +48,45 @@ export class WithdrawHandler { return false; } - // ── Structural validation ──────────────────────────────────────────────── - const structuralFailure = this.eventParser.validateEventStructure(event); - if (structuralFailure) { - await this.eventParser.quarantineEvent( - event, - structuralFailure.reason, - structuralFailure.errorDetails, - 'Withdraw', - ); - return true; + const eventId = this.resolveEventId(event); + const ledgerSequence = + typeof event.ledger === 'number' ? event.ledger : null; + const contractId = + typeof event.contractId === 'string' ? event.contractId : null; + const validationCtx = { + handlerName: 'WithdrawHandler', + eventId, + ledgerSequence, + contractId, + }; + + // Validate the raw event envelope before any decoding + this.contractEventValidator.validateEnvelope(event, validationCtx); + + let payload: WithdrawPayload; + try { + payload = this.extractPayload(event.value); + } catch (err) { + this.logger.error('WithdrawHandler: failed to extract payload', { + handlerName: 'WithdrawHandler', + eventId, + ledgerSequence, + error: (err as Error).message, + }); + throw err; } - // ── Payload parsing ────────────────────────────────────────────────────── - const parseResult = this.eventParser.parseStandardPayload(event, { - eventType: 'Withdraw', - publicKeyFields: [ - 'publicKey', - 'userPublicKey', - 'user', - 'address', - 'to', - 'from', - ], - amountFields: ['amount', 'value', 'amt'], - }); + // Validate the decoded payload against the Withdraw schema + this.contractEventValidator.validatePayload( + 'Withdraw', + payload as unknown as Record, + validationCtx, + ); + + await this.dataSource.transaction(async (manager) => { + const userRepo = manager.getRepository(User); + const txRepo = manager.getRepository(LedgerTransaction); + const subRepo = manager.getRepository(UserSubscription); if (!parseResult.ok) { await this.eventParser.quarantineEvent( @@ -91,11 +115,55 @@ export class WithdrawHandler { ], }); - if (!user) { - throw new Error( - `Cannot map withdraw publicKey to user: ${publicKey}`, - ); - } + const createdTx = await this.transactionStateMachine.createTransaction( + { + userId: user.id, + type: LedgerTransactionType.WITHDRAW, + amount: payload.amount, + publicKey: payload.publicKey, + eventId, + txHash: typeof event.txHash === 'string' ? event.txHash : null, + ledgerSequence: + typeof event.ledger === 'number' ? String(event.ledger) : null, + metadata: { + topic: event.topic, + rawValueType: typeof event.value, + }, + }, + { + manager, + actor: 'blockchain-indexer', + reason: 'Withdraw event ingested', + metadata: { eventId }, + }, + ); + await this.transactionStateMachine.transitionStatus( + createdTx.id, + LedgerTransactionStatus.PENDING_CONFIRMATION, + { + manager, + actor: 'blockchain-indexer', + reason: 'Withdraw pending confirmation', + }, + ); + await this.transactionStateMachine.transitionStatus( + createdTx.id, + LedgerTransactionStatus.CONFIRMED, + { + manager, + actor: 'blockchain-indexer', + reason: 'Withdraw confirmed on ledger', + }, + ); + await this.transactionStateMachine.transitionStatus( + createdTx.id, + LedgerTransactionStatus.COMPLETED, + { + manager, + actor: 'blockchain-indexer', + reason: 'Withdraw workflow completed', + }, + ); const existingTx = await txRepo.findOne({ where: { eventId } }); if (existingTx) { diff --git a/backend/src/modules/blockchain/event-handlers/yield.handler.ts b/backend/src/modules/blockchain/event-handlers/yield.handler.ts index f45993de..7326b1fd 100644 --- a/backend/src/modules/blockchain/event-handlers/yield.handler.ts +++ b/backend/src/modules/blockchain/event-handlers/yield.handler.ts @@ -15,11 +15,21 @@ import { import { User } from '../../user/entities/user.entity'; import { SavingsProduct } from '../../savings/entities/savings-product.entity'; import { TransactionStateMachineService } from '../../transactions/transaction-state-machine.service'; -import { - BlockchainEventParser, - RawBlockchainEvent, -} from '../blockchain-event-parser.service'; -import { QuarantineReason } from '../entities/malformed-blockchain-event.entity'; +import { ContractEventValidatorService } from '../contract-event-validator.service'; + +interface IndexerEvent { + id?: string; + topic?: unknown[]; + value?: unknown; + txHash?: string; + ledger?: number; + [key: string]: unknown; +} + +interface YieldPayload { + publicKey: string; + amount: string; // This represents the interest earned +} @Injectable() export class YieldHandler { @@ -31,7 +41,7 @@ export class YieldHandler { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly transactionStateMachine: TransactionStateMachineService, - private readonly eventParser: BlockchainEventParser, + private readonly contractEventValidator: ContractEventValidatorService, ) {} async handle(event: RawBlockchainEvent): Promise { @@ -39,31 +49,45 @@ export class YieldHandler { return false; } - // ── Structural validation ──────────────────────────────────────────────── - const structuralFailure = this.eventParser.validateEventStructure(event); - if (structuralFailure) { - await this.eventParser.quarantineEvent( - event, - structuralFailure.reason, - structuralFailure.errorDetails, - 'Yield', - ); - return true; + const eventId = this.resolveEventId(event); + const ledgerSequence = + typeof event.ledger === 'number' ? event.ledger : null; + const contractId = + typeof event.contractId === 'string' ? event.contractId : null; + const validationCtx = { + handlerName: 'YieldHandler', + eventId, + ledgerSequence, + contractId, + }; + + // Validate the raw event envelope before any decoding + this.contractEventValidator.validateEnvelope(event, validationCtx); + + let payload: YieldPayload; + try { + payload = this.extractPayload(event.value); + } catch (err) { + this.logger.error('YieldHandler: failed to extract payload', { + handlerName: 'YieldHandler', + eventId, + ledgerSequence, + error: (err as Error).message, + }); + throw err; } - // ── Payload parsing ────────────────────────────────────────────────────── - const parseResult = this.eventParser.parseStandardPayload(event, { - eventType: 'Yield', - publicKeyFields: ['publicKey', 'userPublicKey', 'user', 'address'], - amountFields: [ - 'amount', - 'yield', - 'interest', - 'user_yield', - 'actual_yield', - 'payout', - ], - }); + // Validate the decoded payload against the Yield schema + this.contractEventValidator.validatePayload( + 'Yield', + payload as unknown as Record, + validationCtx, + ); + + await this.dataSource.transaction(async (manager) => { + const userRepo = manager.getRepository(User); + const txRepo = manager.getRepository(LedgerTransaction); + const subRepo = manager.getRepository(UserSubscription); if (!parseResult.ok) { await this.eventParser.quarantineEvent( @@ -92,30 +116,19 @@ export class YieldHandler { ], }); - if (!user) { - throw new Error( - `Cannot map yield publicKey to user: ${publicKey}`, - ); - } - - const existingTx = await txRepo.findOne({ where: { eventId } }); - if (existingTx) { - this.logger.debug( - `Yield event ${eventId} already persisted. Skipping.`, - ); - return; - } - - const createdTx = await this.transactionStateMachine.createTransaction( - { - userId: user.id, - type: LedgerTransactionType.YIELD, - amount, - publicKey, - eventId, - txHash, - ledgerSequence, - metadata: rawMeta, + const createdTx = await this.transactionStateMachine.createTransaction( + { + userId: user.id, + type: LedgerTransactionType.YIELD, + amount: payload.amount, + publicKey: payload.publicKey, + eventId, + txHash: typeof event.txHash === 'string' ? event.txHash : null, + ledgerSequence: + typeof event.ledger === 'number' ? String(event.ledger) : null, + metadata: { + topic: event.topic, + rawValueType: typeof event.value, }, { manager,