Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/basket-quote-asset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'lens': patch
---

`GET /basket` now quotes every component in one explicit asset. It requires a `quote` param, resolves each requested asset against the watched pair that trades it against that quote (issuer-honouring, via the shared `findPairByAsset`), reads only that pair's `pair_key`, and inverts the stored price when the asset is the pair's counter leg. A component with no price in the chosen quote is a `404` naming the asset and quote instead of a total pooled across different quote currencies.
210 changes: 165 additions & 45 deletions src/__tests__/basket.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Fastify from 'fastify'

vi.hoisted(() => {
// Set the watched pairs before `config` builds its per-network cache.
process.env.STELLAR_NETWORK = 'testnet'
process.env.WATCHED_PAIRS_TESTNET =
'XLM/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5,' +
'XLM/EURC:GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2'
})

const { mockQuery } = vi.hoisted(() => ({
mockQuery: vi.fn(),
}))
Expand All @@ -11,6 +19,29 @@ vi.mock('../db', () => ({

import { registerBasketRoutes } from '../routes/basket'

const TESTNET_USDC = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'
const MAINNET_USDC = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'
const EURC_TESTNET = 'GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2'

const XLM = 'XLM'
const USDC = `USDC:${TESTNET_USDC}`
const EURC = `EURC:${EURC_TESTNET}`
const pairKey = (a: string, b: string) => [a, b].sort().join('/')
const USDC_PAIR = pairKey(XLM, USDC)
const EURC_PAIR = pairKey(XLM, EURC)

/**
* A tiny emulation of `price_points`. The route issues exactly one query shape
* (`WHERE pair_key = $1 AND network = $2`), so the mock keys its rows by that
* pair and network and computes the volume-weighted average the SQL would.
*/
type Row = { network: string; pairKey: string; vwap: string | null }
let table: Row[] = []

function setRows(rows: Row[]) {
table = rows
}

async function buildApp() {
const app = Fastify({ logger: false })
await registerBasketRoutes(app)
Expand All @@ -19,105 +50,194 @@ async function buildApp() {
}

describe('GET /basket', () => {
beforeEach(() => vi.clearAllMocks())

it('returns weighted basket price with normalized weights', async () => {
mockQuery.mockImplementation(async (_sql: string, params: string[]) => {
const asset = params[0]
if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] }
if (asset === 'XLM') return { rows: [{ vwap: '0.1' }] }
return { rows: [{ vwap: null }] }
beforeEach(() => {
mockQuery.mockReset()
setRows([
{ network: 'testnet', pairKey: USDC_PAIR, vwap: '0.1' },
{ network: 'testnet', pairKey: EURC_PAIR, vwap: '0.5' },
])
mockQuery.mockImplementation(async (sql: string, params: unknown[]) => {
if (!sql.includes('pair_key = $1') || !sql.includes('network = $2')) {
throw new Error(`unexpected SQL: ${sql}`)
}
const [pk, network] = params as [string, string]
const row = table.find(r => r.pairKey === pk && r.network === network)
return { rows: row ? [{ vwap: row.vwap }] : [] }
})
})

it('quotes every component in the requested asset', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/basket?asset=USDC&weight=1&asset=XLM&weight=1',
url: '/basket?asset=XLM&weight=1&asset=USDC&weight=1&quote=USDC',
})

expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.basketPrice).toBeCloseTo(0.55, 5)
expect(body.quote).toBe('USDC')
expect(body.basketPrice).toBeCloseTo(0.55, 5) // 0.5*0.1 + 0.5*1
expect(body.weightSum).toBeCloseTo(1.0, 5)
expect(body.components).toHaveLength(2)
expect(body.components[0].asset).toBe('USDC')
expect(body.components[0].weight).toBeCloseTo(0.5, 5)
expect(body.components[1].asset).toBe('XLM')
expect(body.components[1].weight).toBeCloseTo(0.5, 5)
expect(body.computedAt).toBeDefined()
expect(body.components[0]).toMatchObject({ asset: 'XLM', price: 0.1, weight: 0.5 })
expect(body.components[1]).toMatchObject({ asset: 'USDC', price: 1, weight: 0.5 })
})

it('normalizes unequal weights to sum to 1.0', async () => {
mockQuery.mockImplementation(async (_sql: string, params: string[]) => {
const asset = params[0]
if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] }
if (asset === 'EURC') return { rows: [{ vwap: '1.1' }] }
return { rows: [{ vwap: null }] }
it('does not pool pairs that share a leg but quote in a different asset', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/basket?asset=XLM&weight=1&asset=USDC&weight=1&quote=USDC',
})

expect(res.statusCode).toBe(200)
expect(res.json().components[0].price).toBeCloseTo(0.1, 5)
// The EURC pair must never be consulted for a USDC-quoted component.
const queriedPairs = mockQuery.mock.calls.map(c => (c[1] as string[])[0])
expect(queriedPairs).toContain(USDC_PAIR)
expect(queriedPairs).not.toContain(EURC_PAIR)
})

it('inverts when the requested asset is the pair counter leg', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/basket?asset=USDC&weight=3&asset=EURC&weight=1',
url: `/basket?asset=${encodeURIComponent(USDC)}&weight=1&asset=XLM&weight=1&quote=XLM`,
})

expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.quote).toBe('XLM')
// Pair stores 0.1 XLM-per-USDC; in quote XLM the component is 10 XLM per USDC.
expect(body.components[0]).toMatchObject({ asset: USDC, price: 10 })
expect(body.components[1]).toMatchObject({ asset: 'XLM', price: 1 })
})

it('matches an issuer-qualified quote against the watched pair', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: `/basket?asset=XLM&weight=1&asset=USDC&weight=1&quote=${encodeURIComponent(USDC)}`,
})

expect(res.statusCode).toBe(200)
expect(res.json().quote).toBe(USDC)
})

it('404s (naming the asset and quote) when the issuer matches no watched pair', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: `/basket?asset=XLM&weight=1&asset=EURC&weight=1&quote=${encodeURIComponent(`USDC:${MAINNET_USDC}`)}`,
})

expect(res.statusCode).toBe(404)
const err = res.json().error
expect(err).toMatch(/XLM/)
expect(err).toMatch(/EURC/)
expect(err).toMatch(/quote/)
})

it('404s when a component has no price in the chosen quote', async () => {
// No rows at all for the EURC pair: XLM cannot be quoted in EURC.
setRows([{ network: 'testnet', pairKey: USDC_PAIR, vwap: '0.1' }])
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: `/basket?asset=XLM&weight=1&asset=EURC&weight=1&quote=${encodeURIComponent(EURC)}`,
})

expect(res.statusCode).toBe(404)
expect(res.json().error).toMatch(/XLM/)
expect(res.json().error).toMatch(/quote EURC/)
})

it('scopes the price lookup to the request network', async () => {
const app = await buildApp()
await app.inject({
method: 'GET',
url: '/basket?asset=XLM&weight=1&asset=USDC&weight=1&quote=USDC',
})

const [sql, params] = mockQuery.mock.calls[0]
expect(sql).toContain('network = $2')
expect(params).toContain('testnet')
})

it('only uses rows for the selected network', async () => {
setRows([
{ network: 'mainnet', pairKey: USDC_PAIR, vwap: '9.9' },
{ network: 'testnet', pairKey: USDC_PAIR, vwap: '0.1' },
])
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/basket?asset=XLM&weight=1&asset=USDC&weight=1&quote=USDC',
})

expect(res.statusCode).toBe(200)
expect(res.json().components[0].price).toBeCloseTo(0.1, 5)
})

it('normalizes unequal weights to sum to 1.0', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/basket?asset=XLM&weight=3&asset=USDC&weight=1&quote=USDC',
})

expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.weightSum).toBeCloseTo(1.0, 5)
// USDC weight = 0.75, EURC weight = 0.25
// basketPrice = 0.75*1.0 + 0.25*1.1 = 0.75 + 0.275 = 1.025
expect(body.basketPrice).toBeCloseTo(1.025, 5)
expect(body.components[0].weight).toBeCloseTo(0.75, 5)
expect(body.components[1].weight).toBeCloseTo(0.25, 5)
expect(body.basketPrice).toBeCloseTo(0.325, 5) // 0.75*0.1 + 0.25*1
})

it('returns 400 when no assets provided', async () => {
const app = await buildApp()
const res = await app.inject({ method: 'GET', url: '/basket' })
const res = await app.inject({ method: 'GET', url: '/basket?quote=USDC' })
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/asset/)
})

it('returns 400 when only one asset provided', async () => {
it('returns 400 when no quote is provided', async () => {
const app = await buildApp()
const res = await app.inject({ method: 'GET', url: '/basket?asset=USDC&weight=1' })
const res = await app.inject({
method: 'GET',
url: '/basket?asset=XLM&weight=1&asset=USDC&weight=1',
})
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/2 assets/)
expect(res.json().error).toMatch(/quote/)
})

it('returns 400 when asset and weight counts do not match', async () => {
it('returns 400 when only one asset provided', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/basket?asset=USDC&asset=XLM&weight=1',
url: '/basket?asset=XLM&weight=1&quote=USDC',
})
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/must match/)
expect(res.json().error).toMatch(/2 assets/)
})

it('returns 400 when a weight is not a positive number', async () => {
it('returns 400 when asset and weight counts do not match', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/basket?asset=USDC&weight=0&asset=XLM&weight=1',
url: '/basket?asset=XLM&asset=USDC&weight=1&quote=USDC',
})
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/positive/)
expect(res.json().error).toMatch(/must match/)
})

it('returns 404 when an asset has no price data', async () => {
mockQuery.mockImplementation(async (_sql: string, params: string[]) => {
const asset = params[0]
if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] }
return { rows: [{ vwap: null }] }
})

it('returns 400 when a weight is not a positive number', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'GET',
url: '/basket?asset=USDC&weight=1&asset=UNKNOWN&weight=1',
url: '/basket?asset=XLM&weight=0&asset=USDC&weight=1&quote=USDC',
})
expect(res.statusCode).toBe(404)
expect(res.json().error).toMatch(/UNKNOWN/)
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/positive/)
})
})
37 changes: 7 additions & 30 deletions src/__tests__/pairIssuerMatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,15 @@ const MAINNET_USDC = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'

// Testnet ships no default watched pair, so without this the pair list is empty
// and every assertion below passes vacuously — including the one that is
// supposed to fail loudly if the issuer is ignored again.
let getNetworkConfig: typeof import('../config').getNetworkConfig

beforeAll(async () => {
process.env.WATCHED_PAIRS_TESTNET = `USDC:${TESTNET_USDC}/XLM`
vi.resetModules()
;({ getNetworkConfig } = await import('../config'))
// supposed to fail loudly if the issuer is ignored again. The real `findPair`
// resolves against `config`, so the env var must be set before `config` first
// builds its per-network cache.
vi.hoisted(() => {
process.env.WATCHED_PAIRS_TESTNET =
'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM'
})

// Mirrors findPair in src/api/rest.ts, which is module-private.
function findPair(assetA: string, assetB: string, network: 'testnet' | 'mainnet') {
const parse = (a: string) => {
if (a.toLowerCase() === 'native') return { code: 'XLM', issuer: null as string | null }
const [code, issuer] = a.split(':')
return { code: (code ?? '').toUpperCase(), issuer: issuer ?? null }
}
const qA = parse(assetA)
const qB = parse(assetB)
const matches = (
q: { code: string; issuer: string | null },
side: { code: string; issuer?: string | null },
) => {
if (q.code !== side.code.toUpperCase()) return false
if (!q.issuer || !side.issuer) return true
return q.issuer === side.issuer
}
return getNetworkConfig(network).pairs.find(
p =>
(matches(qA, p.assetA) && matches(qB, p.assetB)) ||
(matches(qA, p.assetB) && matches(qB, p.assetA)),
)
}
import { findPair } from '../pairMatch'

describe('pair resolution honours the issuer', () => {
it('matches the testnet pair the testnet deployment watches', () => {
Expand Down
44 changes: 2 additions & 42 deletions src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { getCachedPrice, setCachedPrice } from '../redis'
import { getAggregatedPrice } from '../aggregator/vwap'
import { getBestRoute } from '../aggregator/bestRoute'
import { pgPool } from '../db'
import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config'
import { config, getNetworkConfig, activeNetwork } from '../config'
import '../middleware/network' // declares req.network on the FastifyRequest type
import { findPair } from '../pairMatch'
import {
statusResponseSchema,
priceResponseSchema,
Expand All @@ -21,47 +22,6 @@ function makePairKey(a: string, b: string): string {
return [a, b].sort().join('/')
}

/**
* Resolve a requested pair against the ones this network watches.
*
* The issuer is honoured when the caller supplies one. It used to be discarded
* outright — `a.split(':')[0]` — so every asset called USDC was the same asset,
* and a request for Circle's mainnet USDC was answered with testnet USDC's
* price, stamped `"network":"testnet"`, at 1.72 per XLM against a real ~0.18.
*
* That is the worst failure mode an oracle has: not a refusal, but a confident
* wrong number. Asset codes are not unique on Stellar — anyone can issue
* "USDC", and Horizon lists many — so the issuer is the only thing that
* identifies an asset. A bare code still matches on code alone, since callers
* quoting XLM or a single-issuer asset rely on that.
*/
function findPair(assetA: string, assetB: string, network: NetworkName) {
const parse = (a: string) => {
if (a.toLowerCase() === 'native') return { code: 'XLM', issuer: null as string | null }
const [code, issuer] = a.split(':')
return { code: (code ?? '').toUpperCase(), issuer: issuer ?? null }
}
const qA = parse(assetA)
const qB = parse(assetB)

const matches = (
q: { code: string; issuer: string | null },
side: { code: string; issuer?: string | null },
) => {
if (q.code !== side.code.toUpperCase()) return false
// Only enforce the issuer when both sides name one; XLM has none, and a
// caller passing a bare code is not asserting which issuer they meant.
if (!q.issuer || !side.issuer) return true
return q.issuer === side.issuer
}

return getNetworkConfig(network).pairs.find(
p =>
(matches(qA, p.assetA) && matches(qB, p.assetB)) ||
(matches(qA, p.assetB) && matches(qB, p.assetA)),
)
}

export async function registerRESTRoutes(app: FastifyInstance) {
// Validate every response against its declared schema in dev/test (no-op in
// production). Must run before the routes below are registered so they pick
Expand Down
Loading
Loading