Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
82 changes: 82 additions & 0 deletions src/__tests__/aquariusIngester.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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
})
})
116 changes: 116 additions & 0 deletions src/__tests__/networkVenueConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {}

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/')
})
})
44 changes: 44 additions & 0 deletions src/__tests__/soroswapEnabled.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
46 changes: 45 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -106,13 +118,34 @@ 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 ||
'60000',
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}`] ||
Expand All @@ -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}`] ||
Expand All @@ -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),
}
}
Expand Down Expand Up @@ -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 },

Expand Down
2 changes: 2 additions & 0 deletions src/ingest/oracles/reflector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReflectorPrice | null> {
if (!config.oracle.enabled) return null

try {
const rpc = getRpc()
const contract = new Contract(REFLECTOR_CONTRACT_ID)
Expand Down
Loading