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
58 changes: 58 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,64 @@
}
}
},
"/price/{assetA}/{assetB}/median": {
"get": {
"operationId": "getMedianPrice",
"summary": "Get staleness-aware median price across fresh sources with priority fallback",
"parameters": [
{
"name": "assetA",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "assetB",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "freshness",
"in": "query",
"required": false,
"description": "Freshness threshold in seconds (default: 300)",
"schema": {
"type": "integer",
"default": 300
}
},
{
"$ref": "#/components/parameters/NetworkQuery"
}
],
"responses": {
"200": {
"description": "Staleness-aware median price result with included and excluded sources",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"400": {
"description": "Invalid parameters"
},
"404": {
"description": "Pair not watched"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
}
}
}
},
"/pools": {
"get": {
"operationId": "getPools",
Expand Down
37 changes: 37 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,43 @@ paths:
'429':
$ref: '#/components/responses/TooManyRequests'

/price/{assetA}/{assetB}/median:
get:
operationId: getMedianPrice
summary: Get staleness-aware median price across fresh sources with priority fallback
parameters:
- name: assetA
in: path
required: true
schema:
type: string
- name: assetB
in: path
required: true
schema:
type: string
- name: freshness
in: query
required: false
description: 'Freshness threshold in seconds (default: 300)'
schema:
type: integer
default: 300
- $ref: '#/components/parameters/NetworkQuery'
responses:
'200':
description: Staleness-aware median price result with included and excluded sources
content:
application/json:
schema:
type: object
'400':
description: Invalid parameters
'404':
description: Pair not watched
'429':
$ref: '#/components/responses/TooManyRequests'

/pools:
get:
operationId: getPools
Expand Down
56 changes: 56 additions & 0 deletions src/__tests__/configMap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { config, getNetworkConfig, activeNetwork } from '../config'

describe('Per-network config map and resolution (#113)', () => {
it('exposes fully specified config.networks.testnet block', () => {
const testnet = config.networks.testnet
expect(testnet).toBeDefined()
expect(testnet.horizon.url).toMatch(/testnet\.stellar\.org/)
expect(testnet.rpc.url).toMatch(/testnet\.stellar\.org/)
expect(testnet.network.passphrase).toBe('Test SDF Network ; September 2015')
expect(testnet.soroswap.factoryAddress).toBe('CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY')
expect(testnet.aquarius.enabled).toBe(false) // Aquarius has no public testnet deployment
expect(Array.isArray(testnet.pairs)).toBe(true)
expect(testnet.facilitator.feeStroops).toBe(50000)
})

it('exposes fully specified config.networks.mainnet block with coherent mainnet contracts', () => {
const mainnet = config.networks.mainnet
expect(mainnet).toBeDefined()
expect(mainnet.horizon.url).toMatch(/horizon\.stellar\.org/)
expect(mainnet.rpc.url).toBeDefined()
expect(mainnet.network.passphrase).toBe('Public Global Stellar Network ; September 2015')
expect(mainnet.soroswap.factoryAddress).toBe('CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2')
expect(mainnet.aquarius.enabled).toBe(true)
expect(Array.isArray(mainnet.pairs)).toBe(true)
expect(mainnet.pairs.length).toBeGreaterThan(0)
// Coherent default pair on mainnet Circle USDC/XLM
expect(mainnet.pairs[0].pairKey).toContain('GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN')
})

it('getNetworkConfig returns identical cached instance for given network', () => {
const t1 = getNetworkConfig('testnet')
const t2 = getNetworkConfig('testnet')
expect(t1).toBe(t2)

const m1 = getNetworkConfig('mainnet')
const m2 = getNetworkConfig('mainnet')
expect(m1).toBe(m2)
expect(t1).not.toBe(m1)
})

it('active network shortcuts preserve backward compatibility', () => {
expect(config.horizon.url).toBe(config.networks[activeNetwork].horizon.url)
expect(config.rpc.url).toBe(config.networks[activeNetwork].rpc.url)
expect(config.soroswap.factoryAddress).toBe(config.networks[activeNetwork].soroswap.factoryAddress)
expect(config.pairs).toEqual(config.networks[activeNetwork].pairs)
})

it('global shared properties remain network-agnostic', () => {
expect(config.db.url).toBeDefined()
expect(config.redis.url).toBeDefined()
expect(config.api.port).toBeGreaterThan(0)
expect(config.indexer.pollIntervalMs).toBeGreaterThan(0)
expect(config.cache.priceTtl).toBeGreaterThan(0)
})
})
83 changes: 81 additions & 2 deletions src/__tests__/depth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,26 @@ import {
calculateAMMOutput,
calculateAMMPrice,
calculateAMMSpotPrice,
generateAMMDepthLevels
calculateSlippage,
generateAMMDepthLevels,
getDepth,
} from '../pricing/depth'

describe('Depth and slippage calculations', () => {
const { mockQuery } = vi.hoisted(() => ({
mockQuery: vi.fn(),
}))

vi.mock('../db', () => ({
pgPool: {
query: mockQuery,
},
}))

describe('Depth and slippage calculations (#84)', () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe('calculateAMMSpotPrice', () => {
it('calculates correct spot price from reserves', () => {
expect(calculateAMMSpotPrice(1000, 500)).toBe(0.5)
Expand Down Expand Up @@ -54,6 +70,45 @@ describe('Depth and slippage calculations', () => {

expect(price).toBeGreaterThan(0)
})

it('returns 0 for zero or negative amount', () => {
expect(calculateAMMPrice(1000, 500, 0, 30)).toBe(0)
expect(calculateAMMPrice(1000, 500, -10, 30)).toBe(0)
})
})

describe('calculateSlippage against known constant-product curve', () => {
it('verifies exact mathematical slippage on constant product curve', () => {
// In a 1000 / 1000 pool with 0 fees, spot price = 1.0.
// For input 100: output = 1000 * 100 / (1000 + 100) = 100000 / 1100 = 90.909090...
// Execution price = 0.909090...
// Expected slippage = (1 - 0.909090...) / 1 * 100 = 9.090909...%
const spotPrice = 1.0
const execPrice = 100000 / 1100 / 100
const slippage = calculateSlippage(spotPrice, execPrice)
expect(slippage).toBeCloseTo(9.0909, 3)
})

it('confirms that slippage increases monotonically with trade size', () => {
const rA = 10000
const rB = 10000
const spot = calculateAMMSpotPrice(rA, rB) // 1.0

const priceSmall = calculateAMMPrice(rA, rB, 10, 0)
const priceMed = calculateAMMPrice(rA, rB, 500, 0)
const priceLarge = calculateAMMPrice(rA, rB, 2000, 0)

const slipSmall = calculateSlippage(spot, priceSmall)
const slipMed = calculateSlippage(spot, priceMed)
const slipLarge = calculateSlippage(spot, priceLarge)

expect(slipSmall).toBeLessThan(slipMed)
expect(slipMed).toBeLessThan(slipLarge)
})

it('returns 0 slippage when spot price is 0', () => {
expect(calculateSlippage(0, 10)).toBe(0)
})
})

describe('generateAMMDepthLevels', () => {
Expand Down Expand Up @@ -90,4 +145,28 @@ describe('Depth and slippage calculations', () => {
expect(bids.length).toBe(5)
})
})

describe('getDepth endpoint service', () => {
it('returns empty depth response when no pools exist for pair', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] })
const depth = await getDepth('UNKNOWN/XLM', 100, 'testnet')
expect(depth.spotPrice).toBe(0)
expect(depth.executionPrice).toBe(0)
expect(depth.slippagePct).toBe(0)
expect(depth.asks).toEqual([])
expect(depth.bids).toEqual([])
})

it('returns real depth and slippage when pool exists', async () => {
mockQuery.mockResolvedValueOnce({
rows: [{ reserve_a: '10000', reserve_b: '5000', fee_bp: '30' }],
})
const depth = await getDepth('XLM/USDC', 100, 'testnet')
expect(depth.spotPrice).toBe(0.5)
expect(depth.executionPrice).toBeGreaterThan(0)
expect(depth.slippagePct).toBeGreaterThan(0)
expect(depth.asks.length).toBe(10)
expect(depth.bids.length).toBe(10)
})
})
})
Loading