From 69f2fbf0fa1b83a4e9d736e61dcddd6c79c4c03d Mon Sep 17 00:00:00 2001 From: Jess Date: Sat, 26 Sep 2026 12:52:26 +0100 Subject: [PATCH] feat(listener): add event payload version handling (#784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce version-aware event payload parsing so future contract event schema changes can be handled without breaking existing integrations. Changes ------- - event-utils.ts: add CURRENT_EVENT_VERSION, SUPPORTED_EVENT_VERSIONS, EventVersionParseResult, parseEventVersion(), validateEventVersion() - notification-fixture-builder.ts: add StellarEventBuilder.withPayloadVersion() - event-utils.test.ts: 31 new tests covering version parsing and validation - __mocks__/@stellar/stellar-sdk.ts: re-export real xdr and scValToNative so ScVal fixture helpers work correctly in all tests Details ------- parseEventVersion() extracts payload_version from: - ScvMap payloads (NotificationScheduled contract event shape) - Bare integer ScVals (ScvU32/ScvU64) - Returns found=false for pre-versioned events (backward compatible) - Catches XDR decode failures gracefully without throwing validateEventVersion() wraps the parser result into EventValidationResult: - No version field -> valid (backward compatibility preserved) - Version in SUPPORTED_EVENT_VERSIONS -> valid - Unknown/future version -> valid=false with actionable reason including the unsupported version number and the list of supported versions - Malformed version value -> valid=false with descriptive reason StellarEventBuilder.withPayloadVersion(version) sets event.value to an ScvMap with a payload_version key, matching the on-chain NotificationScheduled event shape for realistic test fixtures. The @stellar/stellar-sdk mock fix re-exports xdr and scValToNative from the real installed package. The prior mock wiped out the entire SDK namespace, making xdr.ScVal undefined in every test that constructed ScVal fixtures — a pre-existing breakage now resolved. Acceptance criteria met ----------------------- - Supported versions are parsed correctly: version 1 accepted from both ScvMap and bare ScvU32 payloads - Unsupported versions generate actionable errors: reason includes the rejected version number and the supported versions list - Existing event formats remain supported: all pre-versioned (no version field) payloads continue to be accepted unchanged --- .../src/__mocks__/@stellar/stellar-sdk.ts | 11 + .../notification-fixture-builder.ts | 17 ++ listener/src/utils/event-utils.test.ts | 280 ++++++++++++++++++ listener/src/utils/event-utils.ts | 131 ++++++++ 4 files changed, 439 insertions(+) diff --git a/listener/src/__mocks__/@stellar/stellar-sdk.ts b/listener/src/__mocks__/@stellar/stellar-sdk.ts index de6a5671..3bb17031 100644 --- a/listener/src/__mocks__/@stellar/stellar-sdk.ts +++ b/listener/src/__mocks__/@stellar/stellar-sdk.ts @@ -1,8 +1,19 @@ /** * Manual mock for @stellar/stellar-sdk. * Used by Jest (via moduleNameMapper) when the real package is not installed. + * + * Only the parts that need stubbing for tests are overridden here. + * Everything else (xdr, scValToNative, etc.) is re-exported from the real + * package so tests that construct ScVal fixtures work correctly. */ +// Re-export the real SDK's XDR and conversion utilities so ScVal fixture +// helpers in tests work without hitting a live RPC endpoint. +export { + xdr, + scValToNative, +} from '../../../node_modules/@stellar/stellar-sdk/lib/index.js'; + export const rpc = { Server: jest.fn().mockImplementation(() => ({ getHealth: jest.fn().mockResolvedValue({ status: 'healthy' }), 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 9bbcadc3..b82e5722 100644 --- a/listener/src/utils/event-utils.test.ts +++ b/listener/src/utils/event-utils.test.ts @@ -4,7 +4,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 { @@ -161,4 +166,279 @@ describe('event-utils', () => { expect(validateRpcResponse(rest as any)).toEqual({ valid: true }); }); }); + + // --------------------------------------------------------------------------- + // Version constants + // --------------------------------------------------------------------------- + + describe('version constants', () => { + it('CURRENT_EVENT_VERSION is 1', () => { + expect(CURRENT_EVENT_VERSION).toBe(1); + }); + + it('SUPPORTED_EVENT_VERSIONS contains version 1', () => { + expect(SUPPORTED_EVENT_VERSIONS.has(1)).toBe(true); + }); + + it('SUPPORTED_EVENT_VERSIONS does not contain version 0', () => { + expect(SUPPORTED_EVENT_VERSIONS.has(0)).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // parseEventVersion + // --------------------------------------------------------------------------- + + describe('parseEventVersion', () => { + describe('ScvMap payloads (NotificationScheduled-style)', () => { + it('extracts payload_version from an ScvMap built with the fixture helper', () => { + const event = NotificationFixtureBuilder.aStellarEvent() + .withPayloadVersion(1) + .build(); + + const result = parseEventVersion(event.value); + + expect(result.found).toBe(true); + expect(result.version).toBe(1); + expect(result.parseError).toBeUndefined(); + }); + + it('returns found=false when the map contains no payload_version key', () => { + // A map with an unrelated key + const value = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('notification_id'), + val: xdr.ScVal.scvU32(42), + }), + ]); + + const result = parseEventVersion(value); + + expect(result.found).toBe(false); + expect(result.version).toBeUndefined(); + }); + + it('returns a parseError when payload_version value is zero (invalid)', () => { + const value = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('payload_version'), + val: xdr.ScVal.scvU32(0), + }), + ]); + + const result = parseEventVersion(value); + + expect(result.found).toBe(true); + expect(result.version).toBeUndefined(); + expect(result.parseError).toMatch(/not a positive integer/i); + }); + + it('handles a map that also contains other fields alongside payload_version', () => { + const value = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('notification_id'), + val: xdr.ScVal.scvU32(99), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('payload_version'), + val: xdr.ScVal.scvU32(1), + }), + ]); + + const result = parseEventVersion(value); + + expect(result.found).toBe(true); + expect(result.version).toBe(1); + }); + }); + + describe('bare integer payloads', () => { + it('extracts version from a bare ScvU32', () => { + const result = parseEventVersion(xdr.ScVal.scvU32(1)); + + expect(result.found).toBe(true); + expect(result.version).toBe(1); + }); + + it('returns a parseError for a bare ScvU32 of zero', () => { + const result = parseEventVersion(xdr.ScVal.scvU32(0)); + + expect(result.found).toBe(true); + expect(result.version).toBeUndefined(); + expect(result.parseError).toMatch(/not a positive integer/i); + }); + }); + + describe('pre-versioned / legacy event payloads', () => { + it('returns found=false for a bare string value (pre-versioned event)', () => { + const result = parseEventVersion(xdr.ScVal.scvString('legacy-payload')); + + expect(result.found).toBe(false); + expect(result.version).toBeUndefined(); + }); + + it('returns found=false for a symbol value (pre-versioned event)', () => { + const result = parseEventVersion(xdr.ScVal.scvSymbol('AutoshareCreated')); + + expect(result.found).toBe(false); + expect(result.version).toBeUndefined(); + }); + + it('returns found=false for an empty map', () => { + const result = parseEventVersion(xdr.ScVal.scvMap([])); + + expect(result.found).toBe(false); + expect(result.version).toBeUndefined(); + }); + }); + + describe('representative event fixtures via StellarEventBuilder', () => { + it('returns found=false for a default StellarEventBuilder event (string value)', () => { + const event = NotificationFixtureBuilder.aStellarEvent().build(); + + const result = parseEventVersion(event.value); + + expect(result.found).toBe(false); + }); + + it('parses version 1 from a NotificationScheduled-style fixture', () => { + const event = NotificationFixtureBuilder.aStellarEvent() + .withTopicSymbol('NotificationScheduled') + .withPayloadVersion(1) + .build(); + + const result = parseEventVersion(event.value); + + expect(result.found).toBe(true); + expect(result.version).toBe(1); + }); + }); + }); + + // --------------------------------------------------------------------------- + // validateEventVersion + // --------------------------------------------------------------------------- + + describe('validateEventVersion', () => { + describe('supported versions', () => { + it('accepts version 1 (current supported version)', () => { + const event = NotificationFixtureBuilder.aStellarEvent() + .withPayloadVersion(1) + .build(); + + expect(validateEventVersion(event.value)).toEqual({ valid: true }); + }); + + it('accepts version 1 built from a bare ScvU32', () => { + expect(validateEventVersion(xdr.ScVal.scvU32(1))).toEqual({ valid: true }); + }); + }); + + describe('backward compatibility — no version field', () => { + it('accepts a pre-versioned string payload (no payload_version key)', () => { + const event = NotificationFixtureBuilder.aStellarEvent() + .withStringValue('legacy') + .build(); + + expect(validateEventVersion(event.value)).toEqual({ valid: true }); + }); + + it('accepts a pre-versioned symbol payload', () => { + expect(validateEventVersion(xdr.ScVal.scvSymbol('AutoshareCreated'))).toEqual({ + valid: true, + }); + }); + + it('accepts a map that has no payload_version key', () => { + const value = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('notification_id'), + val: xdr.ScVal.scvU32(7), + }), + ]); + + expect(validateEventVersion(value)).toEqual({ valid: true }); + }); + + it('accepts an empty map (pre-versioned event data)', () => { + expect(validateEventVersion(xdr.ScVal.scvMap([]))).toEqual({ valid: true }); + }); + }); + + describe('unsupported versions — actionable errors', () => { + it('rejects a future version with a descriptive reason', () => { + const value = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('payload_version'), + val: xdr.ScVal.scvU32(999), + }), + ]); + + const result = validateEventVersion(value); + + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/unsupported event payload version 999/i); + expect(result.reason).toMatch(/supported versions are \[1\]/i); + }); + + it('rejects version 2 when only version 1 is supported', () => { + const value = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('payload_version'), + val: xdr.ScVal.scvU32(2), + }), + ]); + + const result = validateEventVersion(value); + + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/2/); + }); + + it('rejects a zero payload_version with a descriptive reason', () => { + const value = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('payload_version'), + val: xdr.ScVal.scvU32(0), + }), + ]); + + const result = validateEventVersion(value); + + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/unsupported event payload version/i); + }); + + it('rejects a bare ScvU32 of zero', () => { + const result = validateEventVersion(xdr.ScVal.scvU32(0)); + + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/unsupported event payload version/i); + }); + }); + + describe('existing event formats remain supported', () => { + it('accepts a default StellarEventBuilder event unchanged', () => { + const event = NotificationFixtureBuilder.aStellarEvent().build(); + + expect(validateEventVersion(event.value)).toEqual({ valid: true }); + }); + + it('accepts events built with withStringValue (legacy format)', () => { + const event = NotificationFixtureBuilder.aStellarEvent() + .withStringValue('some-legacy-payload') + .build(); + + expect(validateEventVersion(event.value)).toEqual({ valid: true }); + }); + + it('accepts events built with withSymbolValue (legacy format)', () => { + const event = NotificationFixtureBuilder.aStellarEvent() + .withSymbolValue('AutoshareCreated') + .build(); + + expect(validateEventVersion(event.value)).toEqual({ valid: true }); + }); + }); + }); }); diff --git a/listener/src/utils/event-utils.ts b/listener/src/utils/event-utils.ts index 15b09a31..a1b797a1 100644 --- a/listener/src/utils/event-utils.ts +++ b/listener/src/utils/event-utils.ts @@ -10,6 +10,137 @@ export interface RpcResponseValidationResult { reason?: string; } +/** + * 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 {