diff --git a/openapi.yaml b/openapi.yaml index 6dffdbac..03542d62 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -396,6 +396,8 @@ paths: type: number fee_bp: type: integer + venue: + type: string timestamp: type: string format: date-time @@ -2601,3 +2603,4 @@ components: properties: message: type: string + diff --git a/src/api/rest.ts b/src/api/rest.ts index ceb505ac..21307476 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -240,7 +240,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { WHERE ids.pool_id IS NOT NULL ) SELECT l.pool_id, l.asset_a, l.asset_b, - l.reserve_a::float, l.reserve_b::float, l.spot_price::float, l.fee_bp, l.timestamp + l.reserve_a::float, l.reserve_b::float, l.spot_price::float, l.fee_bp, l.timestamp, l.venue FROM ids CROSS JOIN LATERAL ( SELECT * FROM pool_snapshots s @@ -281,3 +281,4 @@ export async function registerRESTRoutes(app: FastifyInstance) { } ) } + diff --git a/src/api/schemas.ts b/src/api/schemas.ts index dceedc4b..b7159fd4 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -705,3 +705,4 @@ export function installResponseValidation(app: FastifyInstance): void { } }) } + diff --git a/src/ingest/venues/index.ts b/src/ingest/venues/index.ts index 0ea72a28..ef032935 100644 --- a/src/ingest/venues/index.ts +++ b/src/ingest/venues/index.ts @@ -2,6 +2,7 @@ import { sdexAdapter } from './sdex' import { ammAdapter } from './amm' import { soroswapAdapter } from './soroswap' import { aquariusAdapter } from './aquarius' +import { phoenixAdapter } from './phoenix' import type { VenueAdapter } from './types' export * from './types' @@ -10,6 +11,7 @@ export { sdexAdapter, startSDEXIngester } from './sdex' export { ammAdapter, startAMMIngester } from './amm' export { soroswapAdapter, startSoroswapIngester } from './soroswap' export { aquariusAdapter, startAquariusIngester } from './aquarius' +export { phoenixAdapter, startPhoenixIngester } from './phoenix' /** * Registry of all available venue adapters. @@ -22,3 +24,4 @@ export const venueAdapters: VenueAdapter[] = [ soroswapAdapter, aquariusAdapter, ] + diff --git a/src/ingest/venues/phoenix.ts b/src/ingest/venues/phoenix.ts new file mode 100644 index 00000000..d5ad526a --- /dev/null +++ b/src/ingest/venues/phoenix.ts @@ -0,0 +1,153 @@ +// TODO: Phoenix contract addresses not verified; using TODO placeholder discovery +/** + * Phoenix AMM Venue Adapter + * + * Indexes Phoenix liquidity pool (Stellar classic) reserves via the + * Phoenix AMM API, calculates spot prices from constant-product invariants, + * and stores them in price_points with source = 'Phoenix_amm'. + * + * Issue: #103, #218 + */ + +import { config, activeNetwork, getNetworkConfig, type NetworkName } from '../../config' +import { createLogger } from '../../logger' +import type { WatchedPair } from '../../types' +import type { VenueAdapter, VenuePricePoint } from './types' +import { + getLastPrice, + resetLastPrice, + writeVenuePricePoints, + startVenueAdapter, +} from './runner' +import { fetchWithTimeout } from '../../fetch' + +const log = createLogger('ingester:Phoenix') + +export function _resetLastPrice(): void { + resetLastPrice('Phoenix') +} + +export function _getLastPrice(network: NetworkName, pairKey: string): number | undefined { + return getLastPrice('Phoenix', network, pairKey) +} + +export interface PhoenixPool { + pool_hash: string + reserves: string[] + total_shares: string +} + +export interface PhoenixListResponse { + results?: PhoenixPool[] +} + +export async function fetchPhoenixPools( + pair: WatchedPair, + apiUrl: string = config.Phoenix.apiUrl +): Promise { + try { + const assetAStr = pair.assetA.issuer + ? `${pair.assetA.code}:${pair.assetA.issuer}` + : 'native' + const assetBStr = pair.assetB.issuer + ? `${pair.assetB.code}:${pair.assetB.issuer}` + : 'native' + + const params = new URLSearchParams() + params.append('assets[]', assetAStr) + params.append('assets[]', assetBStr) + + const res = await fetchWithTimeout(`${apiUrl}?${params.toString()}`) + if (!res.ok) return [] + const data = (await res.json()) as PhoenixListResponse + return data.results ?? [] + } catch (err) { + log.error({ pair: pair.pairKey, err }, 'failed to fetch pools') + return [] + } +} + +export function derivePhoenixPricePoints( + pools: PhoenixPool[], + pair: WatchedPair +): VenuePricePoint[] { + return pools.flatMap((pool) => { + const [r0Str, r1Str] = pool.reserves + if (!r0Str || !r1Str) return [] + + const r0 = parseFloat(r0Str) + const r1 = parseFloat(r1Str) + if (r0 <= 0 || r1 <= 0) return [] + + return [ + { + assetA: pair.assetA.code, + assetB: pair.assetB.code, + pairKey: pair.pairKey, + source: 'Phoenix_amm', + poolId: pool.pool_hash, + price: r1 / r0, + baseVolume: 0, + counterVolume: 0, + ledger: 0, + timestamp: new Date(), + }, + ] + }) +} + +export async function ingestPhoenixPair( + pair: WatchedPair, + network: NetworkName = activeNetwork +): Promise { + const pools = await fetchPhoenixPools(pair, getNetworkConfig(network).Phoenix.apiUrl) + if (pools.length === 0) return + + const points = derivePhoenixPricePoints(pools, pair) + if (points.length === 0) return + + await writeVenuePricePoints('Phoenix', points, pair, network) +} + +export const PhoenixAdapter: VenueAdapter = { + name: 'Phoenix', + kind: 'amm', + source: 'Phoenix_amm', + + isEnabled(network: NetworkName): boolean { + return getNetworkConfig(network).Phoenix.enabled + }, + + getPollIntervalMs(_network: NetworkName): number { + return config.indexer.pollIntervalMs + }, + + async discover(pair: WatchedPair, network: NetworkName): Promise { + const pools = await fetchPhoenixPools(pair, getNetworkConfig(network).Phoenix.apiUrl) + return pools.length > 0 ? [pools] : [] + }, + + async read(target: PhoenixPool[], _pair: WatchedPair, _network: NetworkName): Promise { + return target + }, + + derive(raw: PhoenixPool[], _target: PhoenixPool[], pair: WatchedPair, _network: NetworkName): VenuePricePoint[] { + return derivePhoenixPricePoints(raw, pair) + }, + + async write(points: VenuePricePoint[], _target: PhoenixPool[], pair: WatchedPair, network: NetworkName): Promise { + await writeVenuePricePoints('Phoenix', points, pair, network) + }, + + async ingestPair(pair: WatchedPair, network: NetworkName): Promise { + await ingestPhoenixPair(pair, network) + }, + + _resetLastPrice, + _getLastPrice, +} + +export async function startPhoenixIngester(network: NetworkName = activeNetwork): Promise { + await startVenueAdapter(PhoenixAdapter, network) +} +