mirror of
https://github.com/Permissionless-Software-Foundation/psf-bch-api.git
synced 2026-09-21 16:52:00 -07:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+15
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user