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
150 changes: 150 additions & 0 deletions src/__tests__/graphqlPairs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Fastify, { type FastifyInstance } from 'fastify'

const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'
const AQUA_ISSUER = 'GBNZILSTVQZ4ROOOA3KLGGBKLS5O3UMT7GHIHMXHGOSSSTJRXORMSKE8'

const mocks = vi.hoisted(() => {
const usdcIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'
const aquaIssuer = 'GBNZILSTVQZ4ROOOA3KLGGBKLS5O3UMT7GHIHMXHGOSSSTJRXORMSKE8'
return {
getCachedPrice: vi.fn(),
getAggregatedPrice: vi.fn(),
getBestRoute: vi.fn(),
pgPoolQuery: vi.fn(),
pairs: [
{
pairKey: `USDC:${usdcIssuer}/XLM`,
assetA: { code: 'USDC', issuer: usdcIssuer },
assetB: { code: 'XLM', issuer: null },
},
{
pairKey: `AQUA:${aquaIssuer}/XLM`,
assetA: { code: 'AQUA', issuer: aquaIssuer },
assetB: { code: 'XLM', issuer: null },
},
],
}
})

vi.mock('../redis', () => ({ getCachedPrice: mocks.getCachedPrice }))
vi.mock('../db', () => ({ pgPool: { query: mocks.pgPoolQuery }, prisma: {} }))
vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: mocks.getAggregatedPrice }))
vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mocks.getBestRoute }))
vi.mock('../config', () => ({
activeNetwork: 'mainnet',
config: {
pairs: mocks.pairs,
},
}))

import { registerGraphQL } from '../api/graphql'

const PAIRS_QUERY = `query {
pairs {
pairKey
network
assetA {
code
issuer
}
assetB {
code
issuer
}
latestPrice
lastUpdated
}
}`

const LIST_PAIRS_QUERY = `query {
listPairs
}`

describe('GraphQL pairs and listPairs queries', () => {
let app: FastifyInstance

beforeEach(async () => {
vi.clearAllMocks()
mocks.getCachedPrice.mockResolvedValue(null)
mocks.getAggregatedPrice.mockResolvedValue({})
mocks.getBestRoute.mockResolvedValue({ route: 'SDEX' })
app = Fastify({ logger: false })
await registerGraphQL(app)
await app.ready()
})

it('keeps listPairs working for backward compatibility', async () => {
const res = await app.inject({
method: 'POST',
url: '/graphql',
payload: { query: LIST_PAIRS_QUERY },
})
const json = res.json()
expect(json.errors).toBeUndefined()
expect(json.data.listPairs).toEqual([
`USDC:${USDC_ISSUER}/XLM`,
`AQUA:${AQUA_ISSUER}/XLM`,
])
})

it('resolves pairs query carrying full asset metadata, network, and price info', async () => {
const timestamp = new Date('2026-10-17T12:00:00.000Z')
mocks.pgPoolQuery.mockResolvedValue({
rows: [
{
pair_key: `USDC:${USDC_ISSUER}/XLM`,
price: '0.25',
timestamp,
},
],
})

const res = await app.inject({
method: 'POST',
url: '/graphql',
payload: { query: PAIRS_QUERY },
})

const json = res.json()
expect(json.errors).toBeUndefined()
const pairs = json.data.pairs
expect(pairs).toHaveLength(2)

// Check USDC/XLM pair
const usdcPair = pairs.find((p: any) => p.pairKey === `USDC:${USDC_ISSUER}/XLM`)
expect(usdcPair).toBeDefined()
expect(usdcPair.network).toBe('mainnet')
expect(usdcPair.assetA).toEqual({
code: 'USDC',
issuer: USDC_ISSUER,
})
expect(usdcPair.assetB).toEqual({
code: 'XLM',
issuer: null,
})
expect(usdcPair.latestPrice).toBe(0.25)
expect(usdcPair.lastUpdated).toBe(timestamp.toISOString())

// Check quiet AQUA/XLM pair (no rows returned from lateral join)
const aquaPair = pairs.find((p: any) => p.pairKey === `AQUA:${AQUA_ISSUER}/XLM`)
expect(aquaPair).toBeDefined()
expect(aquaPair.network).toBe('mainnet')
expect(aquaPair.assetA).toEqual({
code: 'AQUA',
issuer: AQUA_ISSUER,
})
expect(aquaPair.assetB).toEqual({
code: 'XLM',
issuer: null,
})
expect(aquaPair.latestPrice).toBeNull()
expect(aquaPair.lastUpdated).toBeNull()

// Verify lateral query parameters passed to pgPool.query
expect(mocks.pgPoolQuery).toHaveBeenCalledWith(
expect.stringContaining('CROSS JOIN LATERAL'),
['mainnet', [`USDC:${USDC_ISSUER}/XLM`, `AQUA:${AQUA_ISSUER}/XLM`]]
)
})
})
62 changes: 59 additions & 3 deletions src/api/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getAggregatedPrice } from '../aggregator/vwap'
import { getBestRoute } from '../aggregator/bestRoute'
import { pgPool } from '../db'
import { config, activeNetwork } from '../config'
import { parseAssetStr } from '../pairsRegistry'
import { parseAssetStr, getActivePairs } from '../pairsRegistry'
import { priceEmitter, PRICE_PUBLISHED, type PricePublishedEvent } from '../events'

// Mercurius pubsub topic that carries every new price. A single app-level
Expand Down Expand Up @@ -55,6 +55,20 @@ export function formatGraphQLErrors(execution: ExecutionResult, ctx: MercuriusCo
}

const schema = `
type Asset {
code: String!
issuer: String
}

type Pair {
pairKey: String!
network: String!
assetA: Asset!
assetB: Asset!
latestPrice: Float
lastUpdated: String
}

type AggregatedPrice {
assetA: String!
assetB: String!
Expand Down Expand Up @@ -125,7 +139,8 @@ const schema = `
getPrice(assetA: String!, assetB: String!): AggregatedPrice
getBestRoute(assetA: String!, assetB: String!, amount: Float!): RouteInfo
getPriceHistory(assetA: String!, assetB: String!, window: String!, limit: Int): [PriceBucket]
listPairs: [String]!
listPairs: [String]! @deprecated(reason: "Use 'pairs' instead")
pairs: [Pair!]!
}

type Subscription {
Expand Down Expand Up @@ -240,7 +255,48 @@ const resolvers = {
},

listPairs() {
return config.pairs.map(p => p.pairKey)
return getActivePairs().map(p => p.pairKey)
},

async pairs() {
const activePairs = getActivePairs()
if (activePairs.length === 0) {
return []
}

const result = await pgPool.query(
`SELECT p.pair_key, l.price, l.timestamp
FROM unnest($2::text[]) AS p(pair_key)
CROSS JOIN LATERAL (
SELECT price, timestamp
FROM price_points
WHERE network = $1
AND pair_key = p.pair_key
ORDER BY timestamp DESC
LIMIT 1
) l`,
[activeNetwork, activePairs.map(p => p.pairKey)]
)

const latestPrices = new Map<string, { price: number; timestamp: Date }>()
result.rows.forEach(row => {
latestPrices.set(row.pair_key, {
price: parseFloat(row.price),
timestamp: row.timestamp,
})
})

return activePairs.map(p => {
const latest = latestPrices.get(p.pairKey)
return {
pairKey: p.pairKey,
network: activeNetwork,
assetA: p.assetA,
assetB: p.assetB,
latestPrice: latest?.price ?? null,
lastUpdated: latest?.timestamp ? (latest.timestamp instanceof Date ? latest.timestamp.toISOString() : new Date(latest.timestamp).toISOString()) : null,
}
})
},
},

Expand Down
Loading