From aade1c58f5cc88e54a909695e8bd91dd1e890ce1 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 28 Mar 2026 19:23:10 -0700 Subject: [PATCH] feat(PSF Liquidity): Off by default, new endpoint reports PSF token price in BCH --- .env-example | 4 + production/docker/.env-example | 4 + src/config/env/common.js | 15 +++ src/controllers/rest-api/price/controller.js | 25 +++++ src/controllers/rest-api/price/router.js | 1 + src/use-cases/price-use-cases.js | 57 +++++++++++ .../unit/controllers/price-controller-unit.js | 39 +++++++- test/unit/use-cases/price-use-cases-unit.js | 96 +++++++++++++++++++ 8 files changed, 240 insertions(+), 1 deletion(-) diff --git a/.env-example b/.env-example index 59b22be..f8e010a 100644 --- a/.env-example +++ b/.env-example @@ -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 + diff --git a/production/docker/.env-example b/production/docker/.env-example index 53488be..12a1d3d 100644 --- a/production/docker/.env-example +++ b/production/docker/.env-example @@ -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 + diff --git a/src/config/env/common.js b/src/config/env/common.js index 8e59f86..8bb1d0b 100644 --- a/src/config/env/common.js +++ b/src/config/env/common.js @@ -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 } diff --git a/src/controllers/rest-api/price/controller.js b/src/controllers/rest-api/price/controller.js index 76dd401..192c233 100644 --- a/src/controllers/rest-api/price/controller.js +++ b/src/controllers/rest-api/price/controller.js @@ -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) diff --git a/src/controllers/rest-api/price/router.js b/src/controllers/rest-api/price/router.js index 65e332d..0372efe 100644 --- a/src/controllers/rest-api/price/router.js +++ b/src/controllers/rest-api/price/router.js @@ -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) } diff --git a/src/use-cases/price-use-cases.js b/src/use-cases/price-use-cases.js index 2bbf4a2..0790988 100644 --- a/src/use-cases/price-use-cases.js +++ b/src/use-cases/price-use-cases.js @@ -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} 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 diff --git a/test/unit/controllers/price-controller-unit.js b/test/unit/controllers/price-controller-unit.js index 68e046f..a6bc02c 100644 --- a/test/unit/controllers/price-controller-unit.js +++ b/test/unit/controllers/price-controller-unit.js @@ -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' }) + }) + }) }) diff --git a/test/unit/use-cases/price-use-cases-unit.js b/test/unit/use-cases/price-use-cases-unit.js index cf0f30a..f0c36b2 100644 --- a/test/unit/use-cases/price-use-cases-unit.js +++ b/test/unit/use-cases/price-use-cases-unit.js @@ -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