From 373f389cf10c94372c305b78a22165fc9d0abd6e Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Mon, 19 Oct 2026 10:00:38 +0530 Subject: [PATCH] feat(pricing): add GET /price/:asset/fiat for local currency conversion and naira valuation (#290) --- .changeset/fiat-pricing-naira-290.md | 5 ++ openapi.json | 47 ++++++++++++ openapi.yaml | 30 ++++++++ src/__tests__/fiat.test.ts | 47 ++++++++++++ src/app.ts | 2 + src/routes/fiat.ts | 107 +++++++++++++++++++++++++++ 6 files changed, 238 insertions(+) create mode 100644 .changeset/fiat-pricing-naira-290.md create mode 100644 src/__tests__/fiat.test.ts create mode 100644 src/routes/fiat.ts diff --git a/.changeset/fiat-pricing-naira-290.md b/.changeset/fiat-pricing-naira-290.md new file mode 100644 index 00000000..c221e407 --- /dev/null +++ b/.changeset/fiat-pricing-naira-290.md @@ -0,0 +1,5 @@ +--- +'lens': minor +--- + +Add GET /price/:asset/fiat for local currency conversion and naira valuation (#290) diff --git a/openapi.json b/openapi.json index c9f1adbe..fada0949 100644 --- a/openapi.json +++ b/openapi.json @@ -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", diff --git a/openapi.yaml b/openapi.yaml index 96a83621..9d414e9d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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 diff --git a/src/__tests__/fiat.test.ts b/src/__tests__/fiat.test.ts new file mode 100644 index 00000000..14dca7f9 --- /dev/null +++ b/src/__tests__/fiat.test.ts @@ -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') + }) +}) diff --git a/src/app.ts b/src/app.ts index 405e4c48..87554d96 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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' @@ -180,6 +181,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise = { + 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 = { + 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, + } + }) +}