diff --git a/src/services/sources/stellarDex.js b/src/services/sources/stellarDex.js index 7ea535c..8e2c43d 100644 --- a/src/services/sources/stellarDex.js +++ b/src/services/sources/stellarDex.js @@ -1,4 +1,4 @@ -const { Server } = require('stellar-sdk'); +const { Asset, Horizon } = require('stellar-sdk'); const config = require('../../config'); const logger = require('../../logger'); @@ -6,66 +6,78 @@ 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; } } diff --git a/test/stellarDex.test.js b/test/stellarDex.test.js new file mode 100644 index 0000000..597e97d --- /dev/null +++ b/test/stellarDex.test.js @@ -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 } + ); + }); +});