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
81 changes: 35 additions & 46 deletions src/services/sources/stellarDex.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const { Server } = require('stellar-sdk');
const config = require('../../config');
const logger = require('../../logger');

let server = null;

Expand All @@ -13,60 +12,50 @@ function getServer() {

const XLM_ASSET = { native: true };

async function fetchPrice(assetCode, issuer) {
try {
const horizon = getServer();

let base;
let counter;

if (!issuer || assetCode === 'XLM') {
base = XLM_ASSET;
counter = { code: 'USDC', issuer: config.stellar.usdcIssuer };
} else {
base = { code: assetCode, issuer };
counter = XLM_ASSET;
}

const orderBook = await horizon.orderbook(base, counter === XLM_ASSET ? undefined : counter).limit(1).call();
function derivePriceFromOrderbook(orderBook) {
const asks = orderBook.asks ?? [];
const bids = orderBook.bids ?? [];

if (!orderBook.bids || orderBook.bids.length === 0) {
return null;
}
const hasAsks = asks.length > 0;
const hasBids = bids.length > 0;

const bestBid = parseFloat(orderBook.bids[0].price);
if (!hasAsks && !hasBids) {
return null;
}

if (!issuer || assetCode === 'XLM') {
const xlmUsdcPrice = bestBid;
const xlmUsd = await getXlmUsdPrice(horizon);
if (xlmUsd === null) return null;
return xlmUsdcPrice * xlmUsd;
}
if (hasAsks && hasBids) {
const bestAsk = parseFloat(asks[0].price);
const bestBid = parseFloat(bids[0].price);
return (bestAsk + bestBid) / 2;
}

return bestBid;
} catch (err) {
logger.warn('Stellar DEX price fetch failed', { assetCode, issuer, error: err.message });
return null;
if (hasBids) {
return parseFloat(bids[0].price);
}

return parseFloat(asks[0].price);
}

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

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

return parseFloat(orderBook.bids[0].price);
} catch (err) {
logger.warn('XLM/USDC price fetch failed', { error: err.message });
return null;
if (!issuer || assetCode === 'XLM') {
base = XLM_ASSET;
counter = { code: 'USDC', issuer: config.stellar.usdcIssuer };
} else {
base = { code: assetCode, issuer };
counter = XLM_ASSET;
}

const orderBook = await horizon
.orderbook(base, counter === XLM_ASSET ? undefined : counter)
.limit(1)
.call();

return derivePriceFromOrderbook(orderBook);
}

module.exports = { fetchPrice };
module.exports = { fetchPrice, derivePriceFromOrderbook };
139 changes: 139 additions & 0 deletions test/stellarDex.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
'use strict';

const mockCall = jest.fn();
const mockLimit = jest.fn(() => ({ call: mockCall }));
const mockOrderbook = jest.fn(() => ({ limit: mockLimit }));
const MockServer = jest.fn().mockImplementation(() => ({
orderbook: mockOrderbook,
}));

const mockUsdcIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA';

jest.mock('stellar-sdk', () => ({
Server: MockServer,
}));

jest.mock('../src/config', () => ({
stellar: {
horizonUrl: 'https://horizon.test',
usdcIssuer: mockUsdcIssuer,
},
}));

function loadStellarDex() {
jest.resetModules();
mockCall.mockReset();
mockLimit.mockClear();
mockOrderbook.mockClear();
MockServer.mockClear();
return require('../src/services/sources/stellarDex');
}

function mockOrderbookResponse({ asks = [], bids = [] } = {}) {
mockCall.mockResolvedValueOnce({ asks, bids });
}

describe('Stellar DEX source', () => {
test('returns midpoint of best ask and best bid for a normal orderbook', async () => {
const stellarDex = loadStellarDex();
mockOrderbookResponse({
asks: [{ price: '0.12' }],
bids: [{ price: '0.11' }],
});

const price = await stellarDex.fetchPrice('USDC', mockUsdcIssuer);

expect(price).toBeCloseTo(0.115, 10);
expect(mockOrderbook).toHaveBeenCalledWith(
{ code: 'USDC', issuer: mockUsdcIssuer },
undefined,
);
expect(mockLimit).toHaveBeenCalledWith(1);
});

test('uses best bid only when asks array is empty', async () => {
const stellarDex = loadStellarDex();
mockOrderbookResponse({
asks: [],
bids: [{ price: '0.11' }],
});

const price = await stellarDex.fetchPrice('USDC', mockUsdcIssuer);

expect(price).toBe(0.11);
});

test('uses best ask only when bids array is empty', async () => {
const stellarDex = loadStellarDex();
mockOrderbookResponse({
asks: [{ price: '0.12' }],
bids: [],
});

const price = await stellarDex.fetchPrice('USDC', mockUsdcIssuer);

expect(price).toBe(0.12);
});

test('returns null gracefully when both asks and bids are empty', async () => {
const stellarDex = loadStellarDex();
mockOrderbookResponse({
asks: [],
bids: [],
});

const price = await stellarDex.fetchPrice('USDC', mockUsdcIssuer);

expect(price).toBeNull();
});

test('throws when Horizon returns a non-200 response', async () => {
const stellarDex = loadStellarDex();
mockCall.mockRejectedValueOnce(new Error('Horizon request failed with status 404'));

await expect(stellarDex.fetchPrice('USDC', mockUsdcIssuer)).rejects.toThrow(
'Horizon request failed with status 404',
);
});

test('throws with timeout message when Horizon times out', async () => {
const stellarDex = loadStellarDex();
mockCall.mockRejectedValueOnce(new Error('Request timed out'));

await expect(stellarDex.fetchPrice('USDC', mockUsdcIssuer)).rejects.toThrow(
'Request timed out',
);
});

test('uses native XLM orderbook pair when asset has no issuer', async () => {
const stellarDex = loadStellarDex();
mockOrderbookResponse({
asks: [{ price: '0.12' }],
bids: [{ price: '0.10' }],
});

const price = await stellarDex.fetchPrice('XLM');

expect(price).toBe(0.11);
expect(mockOrderbook).toHaveBeenCalledWith(
{ native: true },
{ code: 'USDC', issuer: mockUsdcIssuer },
);
});
});

describe('Stellar DEX source performance and coverage', () => {
test('completes mocked requests in under 100ms', async () => {
const stellarDex = loadStellarDex();
mockOrderbookResponse({
asks: [{ price: '0.12' }],
bids: [{ price: '0.11' }],
});

const start = Date.now();
await stellarDex.fetchPrice('USDC', mockUsdcIssuer);
const elapsed = Date.now() - start;

expect(elapsed).toBeLessThan(100);
});
});
Loading