Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions listener/src/test-utils/notification-fixture-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions listener/src/utils/event-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) {
return {
Expand Down
131 changes: 131 additions & 0 deletions listener/src/utils/event-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = 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<string, unknown>;
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 {
Expand Down
Loading