Merge pull request #25 from Permissionless-Software-Foundation/ct-unstable

feat(PSF Liquidity): Off by default, new endpoint reports PSF token p…
This commit is contained in:
Chris Troutner
2026-03-28 19:26:22 -07:00
committed by GitHub
8 changed files with 240 additions and 1 deletions
+4
View File
@@ -33,3 +33,7 @@ BASIC_AUTH_TOKEN=some-random-token
# END ACCESS CONTROL
# PSF token liquidity price proxy (GET /v6/price/psf). Off by default.
#PSF_LIQUIDITY_PROXY_ENABLED=true
#PSF_LIQUIDITY_URL=http://192.168.0.126:5000
+4
View File
@@ -35,3 +35,7 @@ USE_BASIC_AUTH=false
# END ACCESS CONTROL
# PSF token liquidity price proxy (GET /v6/price/psf). Off by default.
#PSF_LIQUIDITY_PROXY_ENABLED=true
#PSF_LIQUIDITY_URL=http://192.168.0.126:5000
+15
View File
@@ -43,6 +43,19 @@ const basicAuthDefaults = {
token: process.env.BASIC_AUTH_TOKEN || ''
}
const psfLiquidityUrlEnv = process.env.PSF_LIQUIDITY_URL
const psfLiquidityProxyBaseUrl =
psfLiquidityUrlEnv !== undefined &&
psfLiquidityUrlEnv !== null &&
String(psfLiquidityUrlEnv).trim() !== ''
? String(psfLiquidityUrlEnv).trim().replace(/\/$/, '')
: 'http://192.168.0.126:5000'
const psfLiquidityProxyDefaults = {
enabled: normalizeBoolean(process.env.PSF_LIQUIDITY_PROXY_ENABLED, false),
baseUrl: psfLiquidityProxyBaseUrl
}
export default {
// Server port
port: parseInt(process.env.PORT, 10) || 5942,
@@ -92,6 +105,8 @@ export default {
basicAuth: basicAuthDefaults,
psfLiquidityProxy: psfLiquidityProxyDefaults,
// Version
version
}
@@ -26,6 +26,7 @@ class PriceRESTController {
this.root = this.root.bind(this)
this.getBCHUSD = this.getBCHUSD.bind(this)
this.getPsffppWritePrice = this.getPsffppWritePrice.bind(this)
this.getPsfLiquidityPrice = this.getPsfLiquidityPrice.bind(this)
this.handleError = this.handleError.bind(this)
}
@@ -83,6 +84,30 @@ class PriceRESTController {
}
}
/**
* @api {get} /v6/price/psf PSF token liquidity spot price (proxied)
* @apiName GetPsfLiquidityPrice
* @apiGroup Price
* @apiDescription Proxies GET /price from the PSF token liquidity app when
* `PSF_LIQUIDITY_PROXY_ENABLED` is true. Returns 503 when the proxy is disabled.
*
* @apiExample Example usage:
* curl -X GET "https://api.fullstack.cash/v6/price/psf" -H "accept: application/json"
*
* @apiSuccess {Number} usdPerBCH USD price per 1 BCH
* @apiSuccess {Number} bchBalance BCH balance (liquidity app)
* @apiSuccess {Number} tokenBalance Effective PSF token balance
* @apiSuccess {Number} usdPerToken USD price per 1 PSF token
*/
async getPsfLiquidityPrice (req, res) {
try {
const payload = await this.priceUseCases.getPsfLiquidityPrice()
return res.status(200).json(payload)
} catch (err) {
return this.handleError(err, res)
}
}
handleError (err, res) {
wlogger.error('Error in PriceRESTController:', err)
+1
View File
@@ -44,6 +44,7 @@ class PriceRouter {
this.router.get('/', this.priceController.root)
this.router.get('/bchusd', this.priceController.getBCHUSD)
this.router.get('/psffpp', this.priceController.getPsffppWritePrice)
this.router.get('/psf', this.priceController.getPsfLiquidityPrice)
app.use(this.baseUrl, this.router)
}
+57
View File
@@ -28,6 +28,13 @@ class PriceUseCases {
// Allow axios to be injected for testing
this.axios = localConfig.axios || axios
this.psfLiquidityPriceKeys = [
'usdPerBCH',
'bchBalance',
'tokenBalance',
'usdPerToken'
]
}
/**
@@ -78,6 +85,56 @@ class PriceUseCases {
throw err
}
}
/**
* Proxies PSF token liquidity spot price from the token-liquidity app (GET /price).
* @returns {Promise<Object>} usdPerBCH, bchBalance, tokenBalance, usdPerToken
*/
async getPsfLiquidityPrice () {
try {
const proxy = this.config.psfLiquidityProxy
if (!proxy || !proxy.enabled) {
const err = new Error('PSF liquidity price proxy is disabled')
err.status = 503
throw err
}
const baseUrl = String(proxy.baseUrl).replace(/\/$/, '')
const fullUrl = `${baseUrl}/price`
const opt = {
method: 'get',
url: fullUrl,
timeout: 15000
}
const response = await this.axios.request(opt)
const data = response.data
if (
!data ||
typeof data !== 'object' ||
Array.isArray(data) ||
!this.psfLiquidityPriceKeys.every(
(k) => typeof data[k] === 'number' && !Number.isNaN(data[k])
)
) {
const err = new Error('Invalid response from PSF liquidity price service')
err.status = 502
throw err
}
return {
usdPerBCH: data.usdPerBCH,
bchBalance: data.bchBalance,
tokenBalance: data.tokenBalance,
usdPerToken: data.usdPerToken
}
} catch (err) {
wlogger.error('Error in PriceUseCases.getPsfLiquidityPrice()', err)
throw err
}
}
}
export default PriceUseCases
+38 -1
View File
@@ -20,7 +20,13 @@ describe('#price-controller.js', () => {
mockUseCases = {
price: {
getBCHUSD: sandbox.stub().resolves(250.5),
getPsffppWritePrice: sandbox.stub().resolves(0.08335233)
getPsffppWritePrice: sandbox.stub().resolves(0.08335233),
getPsfLiquidityPrice: sandbox.stub().resolves({
usdPerBCH: 483.1,
bchBalance: 25.65337297,
tokenBalance: 39590.96686314,
usdPerToken: 0.50532753
})
}
}
@@ -113,4 +119,35 @@ describe('#price-controller.js', () => {
assert.deepEqual(res.jsonData, { error: 'PSFFPP failure' })
})
})
describe('#getPsfLiquidityPrice()', () => {
it('should return PSF liquidity price payload on success', async () => {
const req = createMockRequest()
const res = createMockResponse()
await uut.getPsfLiquidityPrice(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, {
usdPerBCH: 483.1,
bchBalance: 25.65337297,
tokenBalance: 39590.96686314,
usdPerToken: 0.50532753
})
assert.isTrue(mockUseCases.price.getPsfLiquidityPrice.calledOnce)
})
it('should handle errors via handleError', async () => {
const error = new Error('proxy off')
error.status = 503
mockUseCases.price.getPsfLiquidityPrice.rejects(error)
const req = createMockRequest()
const res = createMockResponse()
await uut.getPsfLiquidityPrice(req, res)
assert.equal(res.statusValue, 503)
assert.deepEqual(res.jsonData, { error: 'proxy off' })
})
})
})
@@ -83,6 +83,102 @@ describe('#price-use-cases.js', () => {
})
})
describe('#getPsfLiquidityPrice()', () => {
const validBody = {
usdPerBCH: 483.1,
bchBalance: 25.65337297,
tokenBalance: 39590.96686314,
usdPerToken: 0.50532753
}
it('should reject with 503 when proxy config is missing', async () => {
try {
await uut.getPsfLiquidityPrice()
assert.fail('Should have thrown an error')
} catch (err) {
assert.equal(err.status, 503)
assert.include(err.message, 'disabled')
}
})
it('should reject with 503 when proxy is disabled', async () => {
mockConfig.psfLiquidityProxy = {
enabled: false,
baseUrl: 'http://192.168.0.126:5000'
}
try {
await uut.getPsfLiquidityPrice()
assert.fail('Should have thrown an error')
} catch (err) {
assert.equal(err.status, 503)
assert.include(err.message, 'disabled')
}
})
it('should return payload when enabled and upstream returns valid JSON', async () => {
mockConfig.psfLiquidityProxy = {
enabled: true,
baseUrl: 'http://192.168.0.126:5000'
}
mockAxios.request.resolves({ data: { ...validBody } })
const result = await uut.getPsfLiquidityPrice()
assert.deepEqual(result, validBody)
assert.isTrue(mockAxios.request.calledOnce)
const callArgs = mockAxios.request.getCall(0).args[0]
assert.equal(callArgs.method, 'get')
assert.equal(callArgs.url, 'http://192.168.0.126:5000/price')
assert.equal(callArgs.timeout, 15000)
})
it('should normalize base URL trailing slash before /price', async () => {
mockConfig.psfLiquidityProxy = {
enabled: true,
baseUrl: 'http://example.com:5000/'
}
mockAxios.request.resolves({ data: { ...validBody } })
await uut.getPsfLiquidityPrice()
const callArgs = mockAxios.request.getCall(0).args[0]
assert.equal(callArgs.url, 'http://example.com:5000/price')
})
it('should reject with 502 when upstream body is invalid', async () => {
mockConfig.psfLiquidityProxy = {
enabled: true,
baseUrl: 'http://192.168.0.126:5000'
}
mockAxios.request.resolves({ data: { usdPerBCH: 'not-a-number' } })
try {
await uut.getPsfLiquidityPrice()
assert.fail('Should have thrown an error')
} catch (err) {
assert.equal(err.status, 502)
assert.include(err.message, 'Invalid response')
}
})
it('should propagate axios errors', async () => {
mockConfig.psfLiquidityProxy = {
enabled: true,
baseUrl: 'http://192.168.0.126:5000'
}
const error = new Error('network down')
mockAxios.request.rejects(error)
try {
await uut.getPsfLiquidityPrice()
assert.fail('Should have thrown an error')
} catch (err) {
assert.equal(err.message, 'network down')
}
})
})
describe('#getPsffppWritePrice()', () => {
it('should handle errors properly', async () => {
// Note: Full unit testing of getPsffppWritePrice is difficult due to dynamic imports