mirror of
https://github.com/Permissionless-Software-Foundation/psf-bch-api-base.git
synced 2026-09-21 16:52:00 -07:00
Merge pull request #7 from Permissionless-Software-Foundation/ct-unstable
Syncing with upstream, PSF token price
This commit is contained in:
@@ -46,3 +46,8 @@ export USE_BASIC_AUTH=true
|
|||||||
export BASIC_AUTH_TOKEN=some-random-token
|
export BASIC_AUTH_TOKEN=some-random-token
|
||||||
|
|
||||||
# END ACCESS CONTROL
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,8 @@ export USE_BASIC_AUTH=true
|
|||||||
export BASIC_AUTH_TOKEN=some-random-token
|
export BASIC_AUTH_TOKEN=some-random-token
|
||||||
|
|
||||||
# END ACCESS CONTROL
|
# 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
@@ -53,6 +53,19 @@ const basicAuthDefaults = {
|
|||||||
token: process.env.BASIC_AUTH_TOKEN || ''
|
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 {
|
export default {
|
||||||
// Server port
|
// Server port
|
||||||
port: parseInt(process.env.PORT, 10) || 5942,
|
port: parseInt(process.env.PORT, 10) || 5942,
|
||||||
@@ -102,6 +115,8 @@ export default {
|
|||||||
|
|
||||||
basicAuth: basicAuthDefaults,
|
basicAuth: basicAuthDefaults,
|
||||||
|
|
||||||
|
psfLiquidityProxy: psfLiquidityProxyDefaults,
|
||||||
|
|
||||||
// Version
|
// Version
|
||||||
version
|
version
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class PriceRESTController {
|
|||||||
this.root = this.root.bind(this)
|
this.root = this.root.bind(this)
|
||||||
this.getBCHUSD = this.getBCHUSD.bind(this)
|
this.getBCHUSD = this.getBCHUSD.bind(this)
|
||||||
this.getPsffppWritePrice = this.getPsffppWritePrice.bind(this)
|
this.getPsffppWritePrice = this.getPsffppWritePrice.bind(this)
|
||||||
|
this.getPsfLiquidityPrice = this.getPsfLiquidityPrice.bind(this)
|
||||||
this.handleError = this.handleError.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) {
|
handleError (err, res) {
|
||||||
wlogger.error('Error in PriceRESTController:', err)
|
wlogger.error('Error in PriceRESTController:', err)
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class PriceRouter {
|
|||||||
this.router.get('/', this.priceController.root)
|
this.router.get('/', this.priceController.root)
|
||||||
this.router.get('/bchusd', this.priceController.getBCHUSD)
|
this.router.get('/bchusd', this.priceController.getBCHUSD)
|
||||||
this.router.get('/psffpp', this.priceController.getPsffppWritePrice)
|
this.router.get('/psffpp', this.priceController.getPsffppWritePrice)
|
||||||
|
this.router.get('/psf', this.priceController.getPsfLiquidityPrice)
|
||||||
|
|
||||||
app.use(this.baseUrl, this.router)
|
app.use(this.baseUrl, this.router)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ class PriceUseCases {
|
|||||||
|
|
||||||
// Allow axios to be injected for testing
|
// Allow axios to be injected for testing
|
||||||
this.axios = localConfig.axios || axios
|
this.axios = localConfig.axios || axios
|
||||||
|
|
||||||
|
this.psfLiquidityPriceKeys = [
|
||||||
|
'usdPerBCH',
|
||||||
|
'bchBalance',
|
||||||
|
'tokenBalance',
|
||||||
|
'usdPerToken'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +85,56 @@ class PriceUseCases {
|
|||||||
throw err
|
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
|
export default PriceUseCases
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ describe('#price-controller.js', () => {
|
|||||||
mockUseCases = {
|
mockUseCases = {
|
||||||
price: {
|
price: {
|
||||||
getBCHUSD: sandbox.stub().resolves(250.5),
|
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' })
|
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()', () => {
|
describe('#getPsffppWritePrice()', () => {
|
||||||
it('should handle errors properly', async () => {
|
it('should handle errors properly', async () => {
|
||||||
// Note: Full unit testing of getPsffppWritePrice is difficult due to dynamic imports
|
// Note: Full unit testing of getPsffppWritePrice is difficult due to dynamic imports
|
||||||
|
|||||||
Reference in New Issue
Block a user