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
88 changes: 50 additions & 38 deletions src/services/sources/stellarDex.js
Original file line number Diff line number Diff line change
@@ -1,71 +1,83 @@
const { Server } = require('stellar-sdk');
const { Asset, Horizon } = require('stellar-sdk');
const config = require('../../config');
const logger = require('../../logger');

let server = null;

function getServer() {
if (!server) {
server = new Server(config.stellar.horizonUrl);
server = new Horizon.Server(config.stellar.horizonUrl);
}
return server;
}

const XLM_ASSET = { native: true };
function xlmAsset() {
return Asset.native();
}

async function fetchPrice(assetCode, issuer) {
try {
const horizon = getServer();
function usdcAsset() {
return new Asset('USDC', config.stellar.usdcIssuer);
}

let base;
let counter;
function issuedAsset(assetCode, issuer) {
return new Asset(assetCode, issuer);
}

if (!issuer || assetCode === 'XLM') {
base = XLM_ASSET;
counter = { code: 'USDC', issuer: config.stellar.usdcIssuer };
} else {
base = { code: assetCode, issuer };
counter = XLM_ASSET;
}
function midpointFromOrderBook(orderBook) {
const bidPrice = orderBook.bids?.[0]?.price;
const askPrice = orderBook.asks?.[0]?.price;
const bestBid = bidPrice !== undefined ? parseFloat(bidPrice) : null;
const bestAsk = askPrice !== undefined ? parseFloat(askPrice) : null;
const hasBid = Number.isFinite(bestBid) && bestBid > 0;
const hasAsk = Number.isFinite(bestAsk) && bestAsk > 0;

if (hasBid && hasAsk) return (bestBid + bestAsk) / 2;
if (hasBid) return bestBid;
if (hasAsk) return bestAsk;
return null;
}

async function fetchOrderBookMidpoint(horizon, base, counter) {
const orderBook = await horizon.orderbook(base, counter).limit(1).call();
return midpointFromOrderBook(orderBook);
}

const orderBook = await horizon.orderbook(base, counter === XLM_ASSET ? undefined : counter).limit(1).call();
async function fetchPrice(assetCode, issuer) {
try {
const horizon = getServer();
const normalizedCode = assetCode.toUpperCase();

if (!orderBook.bids || orderBook.bids.length === 0) {
if (!issuer && normalizedCode !== 'XLM') {
logger.debug('Stellar DEX issuer required for issued asset', { assetCode });
return null;
}

const bestBid = parseFloat(orderBook.bids[0].price);

if (!issuer || assetCode === 'XLM') {
const xlmUsdcPrice = bestBid;
const xlmUsd = await getXlmUsdPrice(horizon);
if (xlmUsd === null) return null;
return xlmUsdcPrice * xlmUsd;
if (normalizedCode === 'XLM') {
return await fetchOrderBookMidpoint(horizon, xlmAsset(), usdcAsset());
}

return bestBid;
const assetInXlm = await fetchOrderBookMidpoint(
horizon,
issuedAsset(normalizedCode, issuer),
xlmAsset()
);
if (assetInXlm === null) return null;

const xlmUsd = await getXlmUsdPrice(horizon);
if (xlmUsd === null) return null;
return assetInXlm * xlmUsd;
} catch (err) {
logger.warn('Stellar DEX price fetch failed', { assetCode, issuer, error: err.message });
return null;
throw err;
}
}

async function getXlmUsdPrice(horizon) {
try {
const usdcIssuer = config.stellar.usdcIssuer;
const orderBook = await horizon
.orderbook(XLM_ASSET, { code: 'USDC', issuer: usdcIssuer })
.limit(1)
.call();

if (!orderBook.bids || orderBook.bids.length === 0) {
return null;
}

return parseFloat(orderBook.bids[0].price);
return await fetchOrderBookMidpoint(horizon, xlmAsset(), usdcAsset());
} catch (err) {
logger.warn('XLM/USDC price fetch failed', { error: err.message });
return null;
throw err;
}
}

Expand Down
176 changes: 176 additions & 0 deletions test/stellarDex.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
'use strict';

const mockOrderbook = jest.fn();
const mockServer = { orderbook: mockOrderbook };
const mockServerConstructor = jest.fn(() => mockServer);
const mockNativeAsset = { native: true };
const mockAsset = jest.fn(function Asset(code, issuer) {
return { code, issuer };
});
mockAsset.native = jest.fn(() => mockNativeAsset);

jest.mock('stellar-sdk', () => ({
Horizon: {
Server: mockServerConstructor,
},
Asset: mockAsset,
}));

jest.mock('../src/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));

const config = require('../src/config');
const logger = require('../src/logger');
const stellarDex = require('../src/services/sources/stellarDex');

const ISSUER = 'G'.padEnd(56, 'A');

function queueOrderBook(result) {
const call = jest.fn();
if (result instanceof Error) {
call.mockRejectedValue(result);
} else {
call.mockResolvedValue(result);
}

const limit = jest.fn(() => ({ call }));
mockOrderbook.mockImplementationOnce(() => ({ limit }));
return { call, limit };
}

beforeEach(() => {
mockOrderbook.mockReset();
mockServerConstructor.mockClear();
mockAsset.mockClear();
mockAsset.native.mockClear();
logger.warn.mockClear();
logger.debug.mockClear();
});

describe('Stellar DEX source', () => {
test('returns midpoint of best ask and best bid for issued assets', async () => {
queueOrderBook({
bids: [{ price: '2.0' }],
asks: [{ price: '2.2' }],
});
queueOrderBook({
bids: [{ price: '0.10' }],
asks: [{ price: '0.12' }],
});

await expect(stellarDex.fetchPrice('TEST', ISSUER)).resolves.toBeCloseTo(0.231);

expect(mockOrderbook).toHaveBeenNthCalledWith(
1,
{ code: 'TEST', issuer: ISSUER },
mockNativeAsset
);
expect(mockOrderbook).toHaveBeenNthCalledWith(
2,
mockNativeAsset,
{ code: 'USDC', issuer: config.stellar.usdcIssuer }
);
});

test('uses best bid when asks are empty', async () => {
queueOrderBook({
bids: [{ price: '0.11' }],
asks: [],
});

await expect(stellarDex.fetchPrice('XLM')).resolves.toBe(0.11);
});

test('uses best ask when bids are empty', async () => {
queueOrderBook({
bids: [],
asks: [{ price: '0.12' }],
});

await expect(stellarDex.fetchPrice('XLM')).resolves.toBe(0.12);
});

test('returns null when asks and bids are empty', async () => {
queueOrderBook({
bids: [],
asks: [],
});

await expect(stellarDex.fetchPrice('XLM')).resolves.toBeNull();
});

test('returns null for issued assets when issuer is missing', async () => {
await expect(stellarDex.fetchPrice('USDC')).resolves.toBeNull();

expect(mockOrderbook).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
'Stellar DEX issuer required for issued asset',
{ assetCode: 'USDC' }
);
});

test('throws when Horizon returns a non-200 error', async () => {
const error = new Error('Horizon request failed with status 500');
error.response = { status: 500 };
queueOrderBook(error);

await expect(stellarDex.fetchPrice('XLM')).rejects.toThrow('status 500');
expect(logger.warn).toHaveBeenCalledWith(
'Stellar DEX price fetch failed',
expect.objectContaining({ assetCode: 'XLM', error: error.message })
);
});

test('throws timeout errors from Horizon', async () => {
const error = new Error('timeout of 10000ms exceeded');
error.code = 'ECONNABORTED';
queueOrderBook(error);

await expect(stellarDex.fetchPrice('XLM')).rejects.toThrow('timeout');
expect(logger.warn).toHaveBeenCalledWith(
'Stellar DEX price fetch failed',
expect.objectContaining({ assetCode: 'XLM', error: error.message })
);
});

test('throws and logs when XLM/USD conversion lookup fails', async () => {
queueOrderBook({
bids: [{ price: '2.0' }],
asks: [{ price: '2.2' }],
});
const error = new Error('XLM/USDC lookup failed');
queueOrderBook(error);

await expect(stellarDex.fetchPrice('TEST', ISSUER)).rejects.toThrow(
'XLM/USDC lookup failed'
);
expect(logger.warn).toHaveBeenCalledWith('XLM/USDC price fetch failed', {
error: error.message,
});
expect(logger.warn).toHaveBeenCalledWith(
'Stellar DEX price fetch failed',
expect.objectContaining({ assetCode: 'TEST', issuer: ISSUER })
);
});

test('uses the native XLM/USDC orderbook for XLM without issuer', async () => {
queueOrderBook({
bids: [{ price: '0.11' }],
asks: [{ price: '0.12' }],
});

await expect(stellarDex.fetchPrice('XLM')).resolves.toBeCloseTo(0.115);

expect(mockOrderbook).toHaveBeenCalledTimes(1);
expect(mockAsset.native).toHaveBeenCalledTimes(1);
expect(mockAsset).toHaveBeenCalledWith('USDC', config.stellar.usdcIssuer);
expect(mockOrderbook).toHaveBeenCalledWith(
mockNativeAsset,
{ code: 'USDC', issuer: config.stellar.usdcIssuer }
);
});
});