diff --git a/listener/src/test-utils/notification-fixture-builder.ts b/listener/src/test-utils/notification-fixture-builder.ts index f5e7199a..bb5eb30c 100644 --- a/listener/src/test-utils/notification-fixture-builder.ts +++ b/listener/src/test-utils/notification-fixture-builder.ts @@ -358,6 +358,23 @@ export class StellarEventBuilder { return this; } + /** + * Set the event value to an ScvMap containing a `payload_version` key, + * matching the shape of the Soroban `NotificationScheduled` event data. + * + * Events that pre-date versioning should use `withValue` / `withStringValue` + * directly; this helper is specifically for testing version-aware parsing. + */ + withPayloadVersion(version: number): this { + this.event.value = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('payload_version'), + val: xdr.ScVal.scvU32(version), + }), + ]); + return this; + } + build(): StellarSDK.rpc.Api.EventResponse { return this.event as StellarSDK.rpc.Api.EventResponse; } diff --git a/listener/src/utils/event-utils.test.ts b/listener/src/utils/event-utils.test.ts index c8061549..2232748b 100644 --- a/listener/src/utils/event-utils.test.ts +++ b/listener/src/utils/event-utils.test.ts @@ -7,7 +7,12 @@ import { matchesEventFilter, validateEventPayload, validateRpcResponse, + parseEventVersion, + validateEventVersion, + CURRENT_EVENT_VERSION, + SUPPORTED_EVENT_VERSIONS, } from './event-utils'; +import { NotificationFixtureBuilder } from '../test-utils/notification-fixture-builder'; function createValidEvent(overrides: Record = {}) { return { diff --git a/listener/src/utils/event-utils.ts b/listener/src/utils/event-utils.ts index fc82a4da..08a8c76a 100644 --- a/listener/src/utils/event-utils.ts +++ b/listener/src/utils/event-utils.ts @@ -124,6 +124,137 @@ export function describeEventParseError(error: EventParseError): string { return error.field ? `${error.category}:${error.field}` : error.category; } +/** + * The current event payload protocol version understood by this listener. + * Matches `CURRENT_NOTIFICATION_VERSION` in the Soroban contract. + */ +export const CURRENT_EVENT_VERSION = 1; + +/** + * All event payload versions this listener can process. + * Add a new entry here whenever the contract introduces a new schema version. + */ +export const SUPPORTED_EVENT_VERSIONS: ReadonlySet = new Set([1]); + +export interface EventVersionParseResult { + /** Whether a version field was present and could be read. */ + found: boolean; + /** + * The numeric version extracted from the payload. + * `undefined` when `found` is false (no version field present). + */ + version: number | undefined; + /** + * Human-readable reason when the value could not be parsed as a valid + * version number (e.g. wrong XDR type, non-integer). + * Only set when `found` is true but the value is unusable. + */ + parseError?: string; +} + +/** + * Attempt to extract `payload_version` from a raw Soroban `ScVal`. + * + * The Soroban contract serialises versioned event data as either: + * - An `ScvMap` containing a `payload_version` key (e.g. `NotificationScheduled`) + * - A direct `ScvU32` / `ScvU64` integer (future single-field events) + * + * Events that pre-date versioning (no `payload_version` key) return + * `{ found: false, version: undefined }` so callers can apply a default + * without breaking existing integrations. + * + * @param value - Raw `ScVal` from `StellarSDK.rpc.Api.EventResponse.value` + */ +export function parseEventVersion(value: StellarSDK.xdr.ScVal): EventVersionParseResult { + if (value === undefined || value === null) { + return { found: false, version: undefined }; + } + + try { + const native = StellarSDK.scValToNative(value); + + // Case 1: struct/map — look for a payload_version key + if (native !== null && typeof native === 'object' && !Array.isArray(native)) { + const record = native as Record; + const raw = record['payload_version']; + + if (raw === undefined || raw === null) { + // Map present but no payload_version key — pre-versioned event + return { found: false, version: undefined }; + } + + const ver = Number(raw); + if (!Number.isInteger(ver) || ver < 1) { + return { + found: true, + version: undefined, + parseError: `payload_version is not a positive integer: ${raw}`, + }; + } + + return { found: true, version: ver }; + } + + // Case 2: bare integer value (u32 / u64 / i128 all convert to number/bigint) + if (typeof native === 'number' || typeof native === 'bigint') { + const ver = Number(native); + if (!Number.isInteger(ver) || ver < 1) { + return { + found: true, + version: undefined, + parseError: `payload_version integer is not a positive integer: ${native}`, + }; + } + return { found: true, version: ver }; + } + + // Any other type (string, array, boolean…) — not a version field + return { found: false, version: undefined }; + } catch { + // scValToNative threw — XDR is malformed; treat as no version + return { found: false, version: undefined }; + } +} + +/** + * Validate that the version extracted by `parseEventVersion` is supported. + * + * Returns `{ valid: true }` for: + * - Events with no version field (pre-versioning, treated as v1 for + * backward compatibility) + * - Events whose version is in `SUPPORTED_EVENT_VERSIONS` + * + * Returns `{ valid: false, reason }` for: + * - Malformed version values (non-integer, negative) + * - Versions greater than `CURRENT_EVENT_VERSION` (unknown future schema) + * - Versions that were explicitly removed from `SUPPORTED_EVENT_VERSIONS` + */ +export function validateEventVersion(value: StellarSDK.xdr.ScVal): EventValidationResult { + const parsed = parseEventVersion(value); + + // No version field — backward-compatible; accept as v1 + if (!parsed.found) { + return { valid: true }; + } + + // Version field found but could not be parsed as a usable integer + if (parsed.version === undefined) { + return { + valid: false, + reason: `Unsupported event payload version: ${parsed.parseError}`, + }; + } + + if (!SUPPORTED_EVENT_VERSIONS.has(parsed.version)) { + return { + valid: false, + reason: `Unsupported event payload version ${parsed.version}; supported versions are [${[...SUPPORTED_EVENT_VERSIONS].join(', ')}]`, + }; + } + + return { valid: true }; +} + export function validateRpcResponse( response: StellarSDK.rpc.Api.GetEventsResponse | null | undefined ): RpcResponseValidationResult {