diff --git a/.env.example b/.env.example index 5417389..4ec4427 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,10 @@ HORIZON_URL_TESTNET=https://horizon-testnet.stellar.org RPC_URL_TESTNET=https://soroban-testnet.stellar.org NETWORK_PASSPHRASE_TESTNET=Test SDF Network ; September 2015 SOROSWAP_FACTORY_ADDRESS_TESTNET=CDKP5WSEZMDL53VZFPBGCL47WBPKFCN5OPYQVXB3CJWUXHPZRPHSSZ3 +# Soroswap has a testnet deployment; leave enabled. Set to "false" to disable. +SOROSWAP_ENABLED_TESTNET=true +# Aquarius has no public testnet deployment today — disabled by default. +AQUARIUS_ENABLED_TESTNET=false REFLECTOR_CONTRACT_ID_TESTNET= # Comma-separated pairs to watch on testnet. # Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. @@ -53,11 +57,20 @@ RPC_URL_MAINNET=https://your-provider.example.com/soroban-rpc NETWORK_PASSPHRASE_MAINNET=Public Global Stellar Network ; September 2015 # Mainnet Soroswap factory contract address — see https://github.com/soroswap/core SOROSWAP_FACTORY_ADDRESS_MAINNET=CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2 +SOROSWAP_ENABLED_MAINNET=true +AQUARIUS_ENABLED_MAINNET=true # Reflector oracle contract on mainnet — see https://reflector.network REFLECTOR_CONTRACT_ID_MAINNET=CCYXZMNHFXHKF3YEX4VJJ5TH3YHCVZIBPNBGM7C4PJIMCIMNNWDOQYA # Comma-separated pairs to watch on mainnet. WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +# --- Venue endpoint overrides (optional, shared across networks unless a +# paired _TESTNET / _MAINNET variant is set) --- +# Soroswap token-list JSON URL. +SOROSWAP_TOKEN_LIST_URL=https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json +# Aquarius AMM pools API base URL. +AQUARIUS_API_URL=https://amm.aquarius.network/api/v1/pools/ + # --- Back-compat single-network vars (testnet) --- # These are still respected when the paired _TESTNET vars above are unset. # New deployments should prefer the paired vars above. diff --git a/src/__tests__/aquariusIngester.test.ts b/src/__tests__/aquariusIngester.test.ts new file mode 100644 index 0000000..e4ad404 --- /dev/null +++ b/src/__tests__/aquariusIngester.test.ts @@ -0,0 +1,82 @@ +/** + * Unit tests for the Aquarius AMM venue adapter. + */ + +const mocks = vi.hoisted(() => ({ + config: { + aquarius: { enabled: true, apiUrl: 'https://amm.aquarius.network/api/v1/pools/' }, + indexer: { pollIntervalMs: 5000 }, + }, + pairsRegistry: { + getActivePairs: vi.fn().mockReturnValue([]), + }, +})) + +vi.mock('../config', () => ({ config: mocks.config })) +vi.mock('../pairsRegistry', () => mocks.pairsRegistry) +vi.mock('../db', () => ({ upsertPricePoints: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../webhookDispatcher', () => ({ dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined) })) + +import { fetchAquariusPools, startAquariusIngester } from '../ingest/venues/aquarius' + +const mockPair = { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, +} + +describe('fetchAquariusPools', () => { + beforeEach(() => { + vi.clearAllMocks() + global.fetch = vi.fn() + }) + + it('queries the configured Aquarius API URL for the network', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + }) + + await fetchAquariusPools(mockPair as any, 'https://testnet.example.com/pools/') + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('https://testnet.example.com/pools/?') + ) + }) + + it('falls back to config.aquarius.apiUrl when no override is passed', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + }) + + await fetchAquariusPools(mockPair as any) + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(mocks.config.aquarius.apiUrl) + ) + }) + + it('returns an empty array on a non-ok response', async () => { + ;(global.fetch as ReturnType).mockResolvedValue({ ok: false }) + + const result = await fetchAquariusPools(mockPair as any) + expect(result).toEqual([]) + }) +}) + +describe('startAquariusIngester', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not start the polling loop when Aquarius is disabled on the active network', async () => { + mocks.config.aquarius.enabled = false + + await startAquariusIngester() + + expect(mocks.pairsRegistry.getActivePairs).not.toHaveBeenCalled() + + mocks.config.aquarius.enabled = true + }) +}) diff --git a/src/__tests__/networkVenueConfig.test.ts b/src/__tests__/networkVenueConfig.test.ts new file mode 100644 index 0000000..67e0644 --- /dev/null +++ b/src/__tests__/networkVenueConfig.test.ts @@ -0,0 +1,116 @@ +/** + * Unit tests for per-network venue configuration (Soroswap / Aquarius / Reflector). + * + * Each test resets modules and re-imports `../config` after mutating + * `process.env` so the lazy per-network cache in config.ts is rebuilt from + * the env vars set for that test. + */ + +const ENV_KEYS = [ + 'STELLAR_NETWORK', + 'SOROSWAP_ENABLED_TESTNET', + 'SOROSWAP_ENABLED_MAINNET', + 'SOROSWAP_TOKEN_LIST_URL', + 'SOROSWAP_TOKEN_LIST_URL_TESTNET', + 'AQUARIUS_ENABLED_TESTNET', + 'AQUARIUS_ENABLED_MAINNET', + 'AQUARIUS_API_URL', + 'REFLECTOR_CONTRACT_ID_TESTNET', + 'REFLECTOR_CONTRACT_ID_MAINNET', + 'REFLECTOR_ENABLED_TESTNET', +] + +async function loadConfig() { + vi.resetModules() + return await import('../config') +} + +describe('per-network venue config', () => { + const originalEnv: Record = {} + + beforeEach(() => { + for (const key of ENV_KEYS) originalEnv[key] = process.env[key] + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) delete process.env[key] + else process.env[key] = originalEnv[key] + } + }) + + it('defaults Aquarius to disabled on testnet and enabled on mainnet', async () => { + delete process.env.AQUARIUS_ENABLED_TESTNET + delete process.env.AQUARIUS_ENABLED_MAINNET + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').aquarius.enabled).toBe(false) + expect(getNetworkConfig('mainnet').aquarius.enabled).toBe(true) + }) + + it('respects an explicit AQUARIUS_ENABLED_TESTNET=true override', async () => { + process.env.AQUARIUS_ENABLED_TESTNET = 'true' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').aquarius.enabled).toBe(true) + }) + + it('defaults Soroswap to enabled on both networks', async () => { + delete process.env.SOROSWAP_ENABLED_TESTNET + delete process.env.SOROSWAP_ENABLED_MAINNET + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').soroswap.enabled).toBe(true) + expect(getNetworkConfig('mainnet').soroswap.enabled).toBe(true) + }) + + it('disables Soroswap on a network when explicitly set to false', async () => { + process.env.SOROSWAP_ENABLED_TESTNET = 'false' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').soroswap.enabled).toBe(false) + }) + + it('resolves a per-network token-list URL override before falling back to the shared default', async () => { + delete process.env.SOROSWAP_TOKEN_LIST_URL + process.env.SOROSWAP_TOKEN_LIST_URL_TESTNET = 'https://example.com/testnet-tokens.json' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').soroswap.tokenListUrl).toBe( + 'https://example.com/testnet-tokens.json' + ) + expect(getNetworkConfig('mainnet').soroswap.tokenListUrl).toBe( + 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' + ) + }) + + it('disables the Reflector oracle when no contract id is configured for the network', async () => { + delete process.env.REFLECTOR_CONTRACT_ID_TESTNET + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').oracle.reflectorContractId).toBe('') + expect(getNetworkConfig('testnet').oracle.enabled).toBe(false) + }) + + it('enables the Reflector oracle on mainnet where a default contract id exists', async () => { + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('mainnet').oracle.reflectorContractId).not.toBe('') + expect(getNetworkConfig('mainnet').oracle.enabled).toBe(true) + }) + + it('resolves a custom Aquarius API URL override', async () => { + process.env.AQUARIUS_API_URL = 'https://example.com/aquarius/' + + const { getNetworkConfig } = await loadConfig() + + expect(getNetworkConfig('testnet').aquarius.apiUrl).toBe('https://example.com/aquarius/') + expect(getNetworkConfig('mainnet').aquarius.apiUrl).toBe('https://example.com/aquarius/') + }) +}) diff --git a/src/__tests__/soroswapEnabled.test.ts b/src/__tests__/soroswapEnabled.test.ts new file mode 100644 index 0000000..46b190f --- /dev/null +++ b/src/__tests__/soroswapEnabled.test.ts @@ -0,0 +1,44 @@ +/** + * Verifies the Soroswap ingester respects the per-network enable flag, + * skipping the polling loop entirely when Soroswap has no usable deployment + * on the active network. + */ + +const mocks = vi.hoisted(() => ({ + config: { + soroswap: { + enabled: true, + factoryAddress: 'CFACTORY', + tokenListUrl: 'https://example.com/tokens.json', + pollIntervalMs: 60000, + }, + network: { passphrase: 'Test SDF Network ; September 2015' }, + rpc: { url: 'https://soroban-testnet.stellar.org' }, + }, + pairsRegistry: { + getActivePairs: vi.fn().mockReturnValue([]), + }, +})) + +vi.mock('../config', () => ({ config: mocks.config })) +vi.mock('../pairsRegistry', () => mocks.pairsRegistry) +vi.mock('../db', () => ({ upsertPricePoints: vi.fn().mockResolvedValue(undefined) })) +vi.mock('../webhookDispatcher', () => ({ dispatchPriceUpdate: vi.fn().mockResolvedValue(undefined) })) + +import { startSoroswapIngester } from '../ingesters/soroswap' + +describe('startSoroswapIngester', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not start the polling loop when Soroswap is disabled on the active network', async () => { + mocks.config.soroswap.enabled = false + + await startSoroswapIngester() + + expect(mocks.pairsRegistry.getActivePairs).not.toHaveBeenCalled() + + mocks.config.soroswap.enabled = true + }) +}) diff --git a/src/config.ts b/src/config.ts index aea5277..8d8ffd2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,12 +18,24 @@ export interface NetworkConfig { passphrase: string } soroswap: { + /** Whether Soroswap has a usable deployment on this network. */ + enabled: boolean /** Soroswap factory contract address for this network. */ factoryAddress: string + /** Soroswap token-list URL for this network. */ + tokenListUrl: string /** How often to poll Soroswap pool reserves in ms. */ pollIntervalMs: number } + aquarius: { + /** Whether Aquarius has a usable deployment on this network. */ + enabled: boolean + /** Aquarius AMM pools API base URL for this network. */ + apiUrl: string + } oracle: { + /** Whether the Reflector oracle is deployed on this network. */ + enabled: boolean /** Reflector oracle contract ID for this network. */ reflectorContractId: string } @@ -106,6 +118,16 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { ? 'CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2' : 'CDKP5WSEZMDL53VZFPBGCL47WBPKFCN5OPYQVXB3CJWUXHPZRPHSSZ3') + // Soroswap token-list is a single canonical list covering both networks by + // default, but can be overridden per network (e.g. a testnet-specific list). + const soroswapTokenListUrl = + process.env[`SOROSWAP_TOKEN_LIST_URL_${suffix}`] || + process.env.SOROSWAP_TOKEN_LIST_URL || + 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' + + const soroswapEnabled = + (process.env[`SOROSWAP_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' + const soroswapPollMs = parseInt( process.env[`SOROSWAP_POLL_INTERVAL_MS_${suffix}`] || process.env.SOROSWAP_POLL_INTERVAL_MS || @@ -113,6 +135,17 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { 10 ) + // ── Aquarius ────────────────────────────────────────────────────────────── + // Aquarius only runs on Stellar classic mainnet today — there is no public + // testnet deployment, so it is disabled there by default. + const aquariusApiUrl = + process.env[`AQUARIUS_API_URL_${suffix}`] || + process.env.AQUARIUS_API_URL || + 'https://amm.aquarius.network/api/v1/pools/' + + const aquariusEnabled = + (process.env[`AQUARIUS_ENABLED_${suffix}`] ?? (network === 'mainnet' ? 'true' : 'false')).toLowerCase() !== 'false' + // ── Reflector oracle ────────────────────────────────────────────────────── const reflectorContractId = process.env[`REFLECTOR_CONTRACT_ID_${suffix}`] || @@ -121,6 +154,10 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { ? 'CCYXZMNHFXHKF3YEX4VJJ5TH3YHCVZIBPNBGM7C4PJIMCIMNNWDOQYA' : '') + const oracleEnabled = + (process.env[`REFLECTOR_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' && + reflectorContractId !== '' + // ── Watched pairs ───────────────────────────────────────────────────────── const rawPairs = process.env[`WATCHED_PAIRS_${suffix}`] || @@ -132,10 +169,16 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { rpc: { url: rpcUrl }, network: { passphrase }, soroswap: { + enabled: soroswapEnabled, factoryAddress: soroswapFactory, + tokenListUrl: soroswapTokenListUrl, pollIntervalMs: soroswapPollMs, }, - oracle: { reflectorContractId }, + aquarius: { + enabled: aquariusEnabled, + apiUrl: aquariusApiUrl, + }, + oracle: { enabled: oracleEnabled, reflectorContractId }, pairs: parseWatchedPairs(rawPairs), } } @@ -229,6 +272,7 @@ export const config = { get rpc() { return resolveNetwork(activeNetwork).rpc }, get network() { return resolveNetwork(activeNetwork).network }, get soroswap() { return resolveNetwork(activeNetwork).soroswap }, + get aquarius() { return resolveNetwork(activeNetwork).aquarius }, get oracle() { return resolveNetwork(activeNetwork).oracle }, get pairs() { return resolveNetwork(activeNetwork).pairs }, diff --git a/src/ingest/oracles/reflector.ts b/src/ingest/oracles/reflector.ts index 6bcee44..8439246 100644 --- a/src/ingest/oracles/reflector.ts +++ b/src/ingest/oracles/reflector.ts @@ -44,6 +44,8 @@ export interface ReflectorPrice { * Returns null when the contract is unreachable or the asset is unknown. */ export async function fetchReflectorPrice(assetCode: string): Promise { + if (!config.oracle.enabled) return null + try { const rpc = getRpc() const contract = new Contract(REFLECTOR_CONTRACT_ID) diff --git a/src/ingest/venues/aquarius.ts b/src/ingest/venues/aquarius.ts index a609d72..5e0fab3 100644 --- a/src/ingest/venues/aquarius.ts +++ b/src/ingest/venues/aquarius.ts @@ -14,8 +14,6 @@ import { upsertPricePoints } from '../../db' import { dispatchPriceUpdate } from '../../webhookDispatcher' import type { WatchedPair } from '../../types' -const AQUARIUS_AMM_API = 'https://amm.aquarius.network/api/v1/pools/' - const lastPrice = new Map() interface AquariusPool { @@ -28,7 +26,10 @@ interface AquariusListResponse { results?: AquariusPool[] } -export async function fetchAquariusPools(pair: WatchedPair): Promise { +export async function fetchAquariusPools( + pair: WatchedPair, + apiUrl: string = config.aquarius.apiUrl +): Promise { try { const assetAStr = pair.assetA.issuer ? `${pair.assetA.code}:${pair.assetA.issuer}` @@ -41,7 +42,7 @@ export async function fetchAquariusPools(pair: WatchedPair): Promise { + if (!config.aquarius.enabled) { + console.log('[aquarius] Aquarius is disabled on this network — ingester not started') + return + } + console.log(`[aquarius] Starting Aquarius AMM ingester for ${getActivePairs().length} pairs`) while (true) { for (const pair of getActivePairs()) { diff --git a/src/ingesters/soroswap.ts b/src/ingesters/soroswap.ts index b1accc2..fbfa124 100644 --- a/src/ingesters/soroswap.ts +++ b/src/ingesters/soroswap.ts @@ -27,9 +27,6 @@ import type { WatchedPair } from '../types' // ── Constants ───────────────────────────────────────────────────────────────── -const SOROSWAP_TOKEN_LIST_URL = - 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' - // Ephemeral fee-payer account (no real funds needed for simulation) const FEE_PAYER_KEYPAIR = Keypair.random() @@ -71,9 +68,11 @@ function getRpc(): SorobanRpc.Server { * Fetch Soroswap token list. Returns an empty array on failure so the ingester * degrades gracefully without affecting other ingesters. */ -export async function fetchSoroswapTokenList(): Promise { +export async function fetchSoroswapTokenList( + tokenListUrl: string = config.soroswap.tokenListUrl +): Promise { try { - const res = await fetch(SOROSWAP_TOKEN_LIST_URL) + const res = await fetch(tokenListUrl) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = (await res.json()) as SoroswapTokenList return Array.isArray(data.tokens) ? data.tokens : [] @@ -327,7 +326,13 @@ async function sleep(ms: number): Promise { * Fault-isolated: a crash is caught by the caller (restartIngester in index.ts). */ export async function startSoroswapIngester(): Promise { + if (!config.soroswap.enabled) { + console.log('[soroswap] Soroswap is disabled on this network — ingester not started') + return + } + const factoryAddress = config.soroswap.factoryAddress + const tokenListUrl = config.soroswap.tokenListUrl const pollInterval = config.soroswap.pollIntervalMs console.log( @@ -336,7 +341,7 @@ export async function startSoroswapIngester(): Promise { while (true) { const pairs = getActivePairs() - const tokens = await fetchSoroswapTokenList() + const tokens = await fetchSoroswapTokenList(tokenListUrl) if (tokens.length === 0) { console.warn('[soroswap] Token list empty — skipping poll cycle')