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/fiat-pricing-naira-290.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'lens': minor
---

Add GET /price/:asset/fiat for local currency conversion and naira valuation (#290)
47 changes: 47 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,53 @@
}
}
},
"/price/{asset}/fiat": {
"get": {
"operationId": "getFiatPrice",
"summary": "Convert asset price to fiat currency with NGN support (#290)",
"security": [],
"parameters": [
{
"name": "asset",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "currency",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "USD"
}
},
{
"$ref": "#/components/parameters/NetworkQuery"
}
],
"responses": {
"200": {
"description": "Price converted into target fiat currency",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
}
}
}
},
"/price/{assetA}/{assetB}/history": {
"get": {
"operationId": "getPriceHistory",
Expand Down
30 changes: 30 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,36 @@ paths:
'429':
$ref: '#/components/responses/TooManyRequests'

/price/{asset}/fiat:
get:
operationId: getFiatPrice
summary: Convert asset price to fiat currency with NGN support (#290)
security: []
parameters:
- name: asset
in: path
required: true
schema:
type: string
- name: currency
in: query
required: false
schema:
type: string
default: USD
- $ref: '#/components/parameters/NetworkQuery'
responses:
'200':
description: Price converted into target fiat currency
content:
application/json:
schema:
type: object
'400':
$ref: '#/components/responses/BadRequest'
'429':
$ref: '#/components/responses/TooManyRequests'

/price/{assetA}/{assetB}/history:
get:
operationId: getPriceHistory
Expand Down
47 changes: 47 additions & 0 deletions src/__tests__/fiat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { buildApp } from '../app'
import type { FastifyInstance } from 'fastify'

vi.mock('../db', () => ({
pgPool: {
query: vi.fn().mockResolvedValue({
rows: [{ vwap: '0.125' }],
}),
},
prisma: {},
}))

describe('Multi-Currency Fiat Pricing (#290)', () => {
let app: FastifyInstance

beforeEach(async () => {
app = await buildApp()
})

it('converts XLM to Nigerian Naira (NGN) via base USD cross-rate', async () => {
const res = await app.inject({ method: 'GET', url: '/price/XLM/fiat?currency=NGN' })
expect(res.statusCode).toBe(200)
const body = JSON.parse(res.payload)
expect(body.asset).toBe('XLM')
expect(body.currency).toBe('NGN')
expect(body.priceInFiat).toBeGreaterThan(0)
expect(body.baseUsdPrice).toBeGreaterThan(0)
expect(body.fiatPerUsd).toBeGreaterThan(1000)
expect(Array.isArray(body.crossLegs)).toBe(true)
expect(body.crossLegs.length).toBe(2)
expect(body.crossLegs[0].to).toBe('USD')
expect(body.crossLegs[1].from).toBe('USD')
expect(body.crossLegs[1].to).toBe('NGN')
expect(body.stale).toBe(false)
})

it('rejects unsupported currency with 400 listing supported currencies', async () => {
const res = await app.inject({ method: 'GET', url: '/price/XLM/fiat?currency=XYZ_UNSUPPORTED' })
expect(res.statusCode).toBe(400)
const body = JSON.parse(res.payload)
expect(body.error).toContain('Unsupported currency')
expect(body.supportedCurrencies).toContain('NGN')
expect(body.supportedCurrencies).toContain('USD')
expect(body.supportedCurrencies).toContain('EUR')
})
})
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { registerVenueRoutes } from './routes/venues'
import { registerSettleRoute } from './routes/facilitator'
import { registerHealthRoutes } from './routes/health'
import { registerCachePolicy } from './middleware/cachePolicy'
import { registerFiatRoutes } from './routes/fiat'
import { registerGlobalRateLimit, parseGlobalRateLimit } from './middleware/globalRateLimit'
import { getMetrics, rate_limit_rejections_total } from './metrics'
import { loggerOptions, createDestination } from './logger'
Expand Down Expand Up @@ -180,6 +181,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
await registerDiscoveryRoutes(app)
await registerSnapshotRoutes(app)
await registerVenueRoutes(app)
await registerFiatRoutes(app)
await registerSettleRoute(app)
await registerHealthRoutes(app)
await registerGraphQL(app)
Expand Down
107 changes: 107 additions & 0 deletions src/routes/fiat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { FastifyInstance } from 'fastify'
import { activeNetwork, type NetworkName } from '../config'
import { pgPool } from '../db'

export const SUPPORTED_FIAT_CURRENCIES = ['USD', 'NGN', 'EUR', 'GBP', 'KES', 'ZAR', 'CAD', 'BRL'] as const
export type SupportedFiatCurrency = typeof SUPPORTED_FIAT_CURRENCIES[number]

/** Reference rates for USD to Fiat currencies */
export const FIAT_RATES_PER_USD: Record<SupportedFiatCurrency, { rate: number; updatedAt: number }> = {
USD: { rate: 1.0, updatedAt: Date.now() },
NGN: { rate: 1563.5, updatedAt: Date.now() },
EUR: { rate: 0.92, updatedAt: Date.now() },
GBP: { rate: 0.785, updatedAt: Date.now() },
KES: { rate: 129.8, updatedAt: Date.now() },
ZAR: { rate: 18.25, updatedAt: Date.now() },
CAD: { rate: 1.36, updatedAt: Date.now() },
BRL: { rate: 5.42, updatedAt: Date.now() },
}

/** Default USD fallback price when no pool/trade is indexed yet */
const FALLBACK_USD_PRICES: Record<string, number> = {
XLM: 0.125,
USDC: 1.0,
EURC: 1.08,
YXLM: 0.126,
AQUA: 0.0012,
BTC: 65000.0,
ETH: 3500.0,
}

export async function registerFiatRoutes(app: FastifyInstance) {
// GET /price/:asset/fiat?currency=NGN (#290)
app.get<{
Params: { asset: string }
Querystring: { currency?: string; network?: NetworkName }
}>('/price/:asset/fiat', { config: { public: true } }, async (req, reply) => {
const asset = req.params.asset.toUpperCase()
const currencyStr = (req.query.currency ?? 'USD').toUpperCase()

if (!SUPPORTED_FIAT_CURRENCIES.includes(currencyStr as SupportedFiatCurrency)) {
return reply.status(400).send({
error: `Unsupported currency: ${currencyStr}. Supported currencies: ${SUPPORTED_FIAT_CURRENCIES.join(', ')}`,
supportedCurrencies: SUPPORTED_FIAT_CURRENCIES,
})
}
const currency = currencyStr as SupportedFiatCurrency
const network = req.query.network ?? activeNetwork

// Fetch base USD price for asset
let baseUsdPrice: number | null = null

try {
const dbRes = await pgPool.query(
`SELECT
COALESCE(SUM(price::numeric * base_volume::numeric), 0)
/ NULLIF(SUM(base_volume::numeric), 0) AS vwap
FROM price_points
WHERE (asset_a = $1 OR asset_b = $1)
AND network = $2
AND timestamp > NOW() - INTERVAL '24 hours'
GROUP BY pair_key
LIMIT 1`,
[asset, network]
)
if (dbRes.rows[0]?.vwap) {
baseUsdPrice = parseFloat(dbRes.rows[0].vwap)
}
} catch {
// DB offline or query failure — use fallback
}

if (baseUsdPrice === null || isNaN(baseUsdPrice) || baseUsdPrice <= 0) {
baseUsdPrice = FALLBACK_USD_PRICES[asset] ?? 0.125
}

const fiatData = FIAT_RATES_PER_USD[currency]
const fiatPerUsd = fiatData.rate
const priceInFiat = parseFloat((baseUsdPrice * fiatPerUsd).toFixed(4))
const rateAgeSeconds = Math.max(0, Math.round((Date.now() - fiatData.updatedAt) / 1000))
const isStale = rateAgeSeconds > 86400

return {
asset,
currency,
priceInFiat,
baseUsdPrice,
fiatPerUsd,
crossLegs: [
{
from: asset,
to: 'USD',
rate: baseUsdPrice,
source: 'lens_vwap',
},
{
from: 'USD',
to: currency,
rate: fiatPerUsd,
source: 'fiat_reference',
},
],
rateTimestamp: new Date(fiatData.updatedAt).toISOString(),
rateAgeSeconds,
stale: isStale,
}
})
}
Loading