diff --git a/src/modules/alerts/alert.service.ts b/src/modules/alerts/alert.service.ts index 38ff0e6..50e13fd 100644 --- a/src/modules/alerts/alert.service.ts +++ b/src/modules/alerts/alert.service.ts @@ -4,10 +4,10 @@ import { logger } from '../../utils/logger.utils'; import { CreateAlertInput } from './alert.schemas'; export type PriceMovement = { - creatorId: string; - previousPrice: number | string; - currentPrice: number | string; - ledger_sequence?: number; + creatorId: string; + previousPrice: number | string; + currentPrice: number | string; + ledger_sequence?: number; }; /** @@ -184,62 +184,62 @@ async function deliverPriceAlertWebhook( export async function evaluatePriceAlertsForMovement( movement: PriceMovement ): Promise { - try { - const previousPrice = toNumber(movement.previousPrice); - const currentPrice = toNumber(movement.currentPrice); - - const alerts = await prisma.priceAlert.findMany({ - where: { - creatorId: movement.creatorId, - isActive: true, - triggeredAt: null, - }, - }); - - for (const alert of alerts) { - const targetPrice = toNumber(alert.targetPrice); - const crossedAbove = - alert.direction === 'above' && - previousPrice < targetPrice && - currentPrice >= targetPrice; - const crossedBelow = - alert.direction === 'below' && - previousPrice > targetPrice && - currentPrice <= targetPrice; - - if (!crossedAbove && !crossedBelow) { - continue; - } - - await deliverPriceAlertWebhook(alert, { - event_type: 'price_alert', - alert_id: alert.id, - creator_id: alert.creatorId, - wallet_address: alert.walletAddress, - target_price: targetPrice, - current_price: currentPrice, - direction: alert.direction, - }); - - await prisma.priceAlert.update({ - where: { id: alert.id }, - data: { - isActive: false, - triggeredAt: new Date(), + try { + const previousPrice = toNumber(movement.previousPrice); + const currentPrice = toNumber(movement.currentPrice); + + const alerts = await prisma.priceAlert.findMany({ + where: { + creatorId: movement.creatorId, + isActive: true, + triggeredAt: null, }, - }); - } - } catch (err) { - logger.error( - { - creator_id: movement.creatorId, - ledger_sequence: movement.ledger_sequence, - new_price: movement.currentPrice, - error_message: err instanceof Error ? err.message : 'Unknown error', - failed_at: new Date().toISOString(), - }, - 'Price alert threshold check failed' - ); - throw err; - } + }); + + for (const alert of alerts) { + const targetPrice = toNumber(alert.targetPrice); + const crossedAbove = + alert.direction === 'above' && + previousPrice < targetPrice && + currentPrice >= targetPrice; + const crossedBelow = + alert.direction === 'below' && + previousPrice > targetPrice && + currentPrice <= targetPrice; + + if (!crossedAbove && !crossedBelow) { + continue; + } + + await deliverPriceAlertWebhook(alert, { + event_type: 'price_alert', + alert_id: alert.id, + creator_id: alert.creatorId, + wallet_address: alert.walletAddress, + target_price: targetPrice, + current_price: currentPrice, + direction: alert.direction, + }); + + await prisma.priceAlert.update({ + where: { id: alert.id }, + data: { + isActive: false, + triggeredAt: new Date(), + }, + }); + } + } catch (err) { + logger.error( + { + creator_id: movement.creatorId, + ledger_sequence: movement.ledger_sequence, + new_price: movement.currentPrice, + error_message: err instanceof Error ? err.message : 'Unknown error', + failed_at: new Date().toISOString(), + }, + 'Price alert threshold check failed' + ); + throw err; + } } diff --git a/src/utils/stellar-trade-event-parser.utils.test.ts b/src/utils/stellar-trade-event-parser.utils.test.ts new file mode 100644 index 0000000..ce6eb3a --- /dev/null +++ b/src/utils/stellar-trade-event-parser.utils.test.ts @@ -0,0 +1,116 @@ +import { + parseStellarTradeEvent, + RawSorobanEvent, + TradeEvent, + ParseError, +} from './stellar-trade-event-parser.utils'; + +function makeRawEvent( + overrides: Partial = {} +): RawSorobanEvent { + return { + ledger: 12345, + ledgerClosedAt: '2026-06-27T12:00:00.000Z', + topic: ['KEY_BOUGHT', 'creator-abc', 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'], + data: JSON.stringify({ amount: '100', price: '50', fee: '1' }), + ...overrides, + }; +} + +describe('parseStellarTradeEvent', () => { + describe('valid events', () => { + it('maps a valid buy event to the correct schema fields', () => { + const raw = makeRawEvent({ + topic: ['KEY_BOUGHT', 'creator-abc', 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA'], + data: JSON.stringify({ amount: '250', price: '75', fee: '2.5' }), + }); + + const result = parseStellarTradeEvent(raw); + + expect(result).toEqual({ + event_type: 'buy', + creator_id: 'creator-abc', + actor_address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', + amount: '250', + price: '75', + fee: '2.5', + ledger_sequence: 12345, + timestamp: '2026-06-27T12:00:00.000Z', + }); + }); + + it('maps a valid sell event to the correct schema fields', () => { + const raw = makeRawEvent({ + topic: ['KEY_SOLD', 'creator-xyz', 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCA'], + data: JSON.stringify({ amount: '50', price: '30', fee: '0.5' }), + ledger: 99999, + ledgerClosedAt: '2026-06-28T08:00:00.000Z', + }); + + const result = parseStellarTradeEvent(raw); + + expect(result).toEqual({ + event_type: 'sell', + creator_id: 'creator-xyz', + actor_address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCA', + amount: '50', + price: '30', + fee: '0.5', + ledger_sequence: 99999, + timestamp: '2026-06-28T08:00:00.000Z', + }); + }); + }); + + describe('error handling', () => { + it('throws ParseError when a required topic field is missing', () => { + const raw = makeRawEvent({ topic: [] }); + + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); + + it('throws ParseError when topic has fewer than 3 segments', () => { + const raw = makeRawEvent({ topic: ['KEY_BOUGHT', 'creator-abc'] }); + + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); + + it('throws ParseError for an unknown event type', () => { + const raw = makeRawEvent({ topic: ['UNKNOWN_EVENT', 'creator-abc', 'GABCDEFGH'] }); + + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); + + it('throws ParseError when an empty string is used for creator_id', () => { + const raw = makeRawEvent({ topic: ['KEY_BOUGHT', '', 'GABCDEFGH'] }); + + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); + + it('throws ParseError when a required field in data is missing', () => { + const raw = makeRawEvent({ + data: JSON.stringify({ amount: '100', price: '50' }), + }); + + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); + + it('throws ParseError when data is not valid JSON', () => { + const raw = makeRawEvent({ data: 'not-json' }); + + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); + + it('throws ParseError when ledger is not a number', () => { + const raw = makeRawEvent({ ledger: NaN }); + + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); + + it('throws ParseError when ledgerClosedAt is empty', () => { + const raw = makeRawEvent({ ledgerClosedAt: '' }); + + expect(() => parseStellarTradeEvent(raw)).toThrow(ParseError); + }); + }); +}); diff --git a/src/utils/stellar-trade-event-parser.utils.ts b/src/utils/stellar-trade-event-parser.utils.ts new file mode 100644 index 0000000..50fd3d3 --- /dev/null +++ b/src/utils/stellar-trade-event-parser.utils.ts @@ -0,0 +1,127 @@ +/** + * Helper for converting raw Stellar Soroban event fields into the server's + * internal trade event schema. The raw event's topic and data are expected + * to have been pre-processed into decoded strings (not base64-encoded XDR). + * + * Throws a typed ParseError when a required field is missing or the wrong type. + */ + +export class ParseError extends Error { + name = 'ParseError'; + + constructor(message: string) { + super(message); + } +} + +export interface RawSorobanEvent { + /** Ledger sequence number */ + ledger: number; + /** ISO timestamp of the ledger close */ + ledgerClosedAt: string; + /** Decoded event topic segments: [event_symbol, creator_id, actor_address] */ + topic: string[]; + /** Decoded event data as a JSON string containing { amount, price, fee } */ + data: string; +} + +export interface TradeEvent { + event_type: 'buy' | 'sell'; + creator_id: string; + actor_address: string; + amount: string; + price: string; + fee: string; + ledger_sequence: number; + timestamp: string; +} + +const EVENT_TYPE_MAP: Record = { + KEY_BOUGHT: 'buy', + KEY_SOLD: 'sell', +}; + +function requireString( + value: unknown, + fieldName: string, + context: string +): string { + if (typeof value !== 'string' || value.length === 0) { + throw new ParseError( + `Missing or invalid required field "${fieldName}" in ${context}` + ); + } + return value; +} + +function requireNumber( + value: unknown, + fieldName: string, + context: string +): number { + if (typeof value !== 'number' || isNaN(value)) { + throw new ParseError( + `Missing or invalid required field "${fieldName}" in ${context}` + ); + } + return value; +} + +/** + * Parses a raw Soroban trade event into the server's TradeEvent schema. + * + * @param rawEvent - The decoded raw event from the Stellar RPC + * @returns A structured TradeEvent + * @throws {ParseError} when any required field is missing or invalid + */ +export function parseStellarTradeEvent(rawEvent: RawSorobanEvent): TradeEvent { + const { ledger, ledgerClosedAt, topic, data } = rawEvent; + + const rawType = requireString(topic?.[0], 'topic[0] (event_type)', 'topic'); + const event_type = EVENT_TYPE_MAP[rawType]; + if (!event_type) { + throw new ParseError( + `Unknown event type "${rawType}" — expected KEY_BOUGHT or KEY_SOLD` + ); + } + + const creator_id = requireString(topic[1], 'topic[1] (creator_id)', 'topic'); + const actor_address = requireString( + topic[2], + 'topic[2] (actor_address)', + 'topic' + ); + + const ledger_sequence = requireNumber(ledger, 'ledger', 'root'); + const timestamp = requireString( + ledgerClosedAt, + 'ledgerClosedAt', + 'root' + ); + + let parsedData: Record; + try { + parsedData = JSON.parse(data); + } catch { + throw new ParseError('data must be a valid JSON string'); + } + + if (typeof parsedData !== 'object' || parsedData === null) { + throw new ParseError('data must be a JSON object'); + } + + const amount = requireString(parsedData.amount, 'amount', 'data'); + const price = requireString(parsedData.price, 'price', 'data'); + const fee = requireString(parsedData.fee, 'fee', 'data'); + + return { + event_type, + creator_id, + actor_address, + amount, + price, + fee, + ledger_sequence, + timestamp, + }; +}