Skip to content
Merged
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
122 changes: 61 additions & 61 deletions src/modules/alerts/alert.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -184,62 +184,62 @@ async function deliverPriceAlertWebhook(
export async function evaluatePriceAlertsForMovement(
movement: PriceMovement
): Promise<void> {
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;
}
}
116 changes: 116 additions & 0 deletions src/utils/stellar-trade-event-parser.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
parseStellarTradeEvent,
RawSorobanEvent,
TradeEvent,
ParseError,
} from './stellar-trade-event-parser.utils';

function makeRawEvent(
overrides: Partial<RawSorobanEvent> = {}
): 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<TradeEvent>({
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<TradeEvent>({
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);
});
});
});
127 changes: 127 additions & 0 deletions src/utils/stellar-trade-event-parser.utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, 'buy' | 'sell'> = {
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<string, unknown>;
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,
};
}
Loading