diff --git a/src/services/sources/stellarDex.js b/src/services/sources/stellarDex.js index 7ea535c..3503f7e 100644 --- a/src/services/sources/stellarDex.js +++ b/src/services/sources/stellarDex.js @@ -1,6 +1,5 @@ const { Server } = require('stellar-sdk'); const config = require('../../config'); -const logger = require('../../logger'); let server = null; @@ -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 }; diff --git a/test/stellarDex.test.js b/test/stellarDex.test.js new file mode 100644 index 0000000..dc3edfe --- /dev/null +++ b/test/stellarDex.test.js @@ -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); + }); +});