psf-bch-api 1.3.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env-local +24 -5
- package/bin/server.js +60 -9
- package/package.json +3 -2
- package/src/config/env/common.js +9 -2
- package/src/config/x402.js +7 -0
- package/src/controllers/rest-api/index.js +4 -0
- package/src/controllers/rest-api/price/controller.js +96 -0
- package/src/controllers/rest-api/price/router.js +52 -0
- package/src/middleware/basic-auth.js +61 -0
- package/src/use-cases/index.js +2 -0
- package/src/use-cases/price-use-cases.js +83 -0
- package/test/unit/controllers/price-controller-unit.js +116 -0
- package/test/unit/controllers/rest-api-index-unit.js +8 -0
- package/test/unit/use-cases/price-use-cases-unit.js +103 -0
- /package/{index.js → psf-bch-api.js} +0 -0
package/.env-local
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
|
+
# START INFRASTRUCTURE SETUP
|
|
2
|
+
|
|
1
3
|
# Full Node Connection
|
|
2
4
|
RPC_BASEURL=http://172.17.0.1:8332
|
|
3
5
|
RPC_USERNAME=bitcoin
|
|
4
6
|
RPC_PASSWORD=password
|
|
5
7
|
|
|
6
|
-
# x402 payments required to access this API?
|
|
7
|
-
X402_ENABLED=false
|
|
8
|
-
|
|
9
8
|
# Fulcrum Indexer
|
|
10
|
-
FULCRUM_API=http://
|
|
9
|
+
FULCRUM_API=http://172.17.0.1:3001/v1
|
|
11
10
|
|
|
12
11
|
# SLP Indexer
|
|
13
|
-
SLP_INDEXER_API=http://
|
|
12
|
+
SLP_INDEXER_API=http://localhost:5010
|
|
13
|
+
|
|
14
|
+
# REST API URL for wallet operations
|
|
15
|
+
LOCAL_RESTURL=http://localhost:5942/v6
|
|
16
|
+
|
|
17
|
+
# END INFRASTRUCTURE SETUP
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# START ACCESS CONTROL
|
|
21
|
+
|
|
22
|
+
# x402 payments required to access this API?
|
|
23
|
+
X402_ENABLED=true
|
|
24
|
+
SERVER_BCH_ADDRESS=bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
|
|
25
|
+
FACILITATOR_URL=http://localhost:4345/facilitator
|
|
26
|
+
|
|
27
|
+
# Basic Authentication required to access this API?
|
|
28
|
+
USE_BASIC_AUTH=true
|
|
29
|
+
BASIC_AUTH_TOKEN=some-random-token
|
|
30
|
+
|
|
31
|
+
# END ACCESS CONTROL
|
|
32
|
+
|
package/bin/server.js
CHANGED
|
@@ -15,7 +15,8 @@ import { dirname, join } from 'path'
|
|
|
15
15
|
import config from '../src/config/index.js'
|
|
16
16
|
import Controllers from '../src/controllers/index.js'
|
|
17
17
|
import wlogger from '../src/adapters/wlogger.js'
|
|
18
|
-
import { buildX402Routes, getX402Settings } from '../src/config/x402.js'
|
|
18
|
+
import { buildX402Routes, getX402Settings, getBasicAuthSettings } from '../src/config/x402.js'
|
|
19
|
+
import { basicAuthMiddleware } from '../src/middleware/basic-auth.js'
|
|
19
20
|
|
|
20
21
|
// Load environment variables
|
|
21
22
|
dotenv.config()
|
|
@@ -60,6 +61,7 @@ class Server {
|
|
|
60
61
|
const app = express()
|
|
61
62
|
|
|
62
63
|
const x402Settings = getX402Settings()
|
|
64
|
+
const basicAuthSettings = getBasicAuthSettings()
|
|
63
65
|
|
|
64
66
|
// MIDDLEWARE START
|
|
65
67
|
app.use(express.json())
|
|
@@ -72,23 +74,72 @@ class Server {
|
|
|
72
74
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
|
|
73
75
|
}))
|
|
74
76
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
+
// Apply basic auth middleware if enabled
|
|
78
|
+
// This must run before x402 middleware to set req.locals.basicAuthValid
|
|
79
|
+
if (basicAuthSettings.enabled) {
|
|
80
|
+
wlogger.info('Basic auth middleware enabled')
|
|
81
|
+
app.use(basicAuthMiddleware)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Apply x402 middleware based on configuration
|
|
85
|
+
// Logic:
|
|
86
|
+
// - If X402_ENABLED=false OR USE_BASIC_AUTH=false: Don't apply x402 (no rate limits)
|
|
87
|
+
// - If X402_ENABLED=true AND USE_BASIC_AUTH=true: Apply x402 conditionally (bypass if basic auth valid)
|
|
88
|
+
|
|
89
|
+
// Apply access control middleware based on configuration
|
|
90
|
+
if (x402Settings.enabled && basicAuthSettings.enabled) {
|
|
91
|
+
// X402_ENABLED=true AND USE_BASIC_AUTH=true: Apply x402 conditionally
|
|
77
92
|
const routes = buildX402Routes(this.config.apiPrefix)
|
|
78
93
|
const facilitatorOptions = x402Settings.facilitatorUrl
|
|
79
94
|
? { url: x402Settings.facilitatorUrl }
|
|
80
95
|
: undefined
|
|
81
96
|
|
|
82
|
-
wlogger.info(`x402 middleware enabled; enforcing ${x402Settings.priceSat} satoshis per request`)
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
wlogger.info(`x402 middleware enabled with basic auth bypass; enforcing ${x402Settings.priceSat} satoshis per request (unless basic auth provided)`)
|
|
98
|
+
|
|
99
|
+
// Create conditional x402 middleware that bypasses if basic auth is valid
|
|
100
|
+
const conditionalX402Middleware = (req, res, next) => {
|
|
101
|
+
// If basic auth is valid, bypass x402
|
|
102
|
+
if (req.locals?.basicAuthValid === true) {
|
|
103
|
+
return next()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Otherwise, apply x402 middleware
|
|
107
|
+
return x402PaymentMiddleware(
|
|
85
108
|
x402Settings.serverAddress,
|
|
86
109
|
routes,
|
|
87
110
|
facilitatorOptions
|
|
88
|
-
)
|
|
89
|
-
|
|
111
|
+
)(req, res, next)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
app.use(conditionalX402Middleware)
|
|
115
|
+
} else if (basicAuthSettings.enabled && !x402Settings.enabled) {
|
|
116
|
+
// USE_BASIC_AUTH=true AND X402_ENABLED=false: Require basic auth, reject unauthenticated requests
|
|
117
|
+
wlogger.info('Basic auth enforcement enabled (x402 disabled)')
|
|
118
|
+
|
|
119
|
+
// Middleware that rejects requests without valid basic auth
|
|
120
|
+
const requireBasicAuthMiddleware = (req, res, next) => {
|
|
121
|
+
// Skip auth check for health endpoint and root
|
|
122
|
+
if (req.path === '/health' || req.path === '/') {
|
|
123
|
+
return next()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// If basic auth is valid, allow the request
|
|
127
|
+
if (req.locals?.basicAuthValid === true) {
|
|
128
|
+
return next()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Reject unauthenticated requests
|
|
132
|
+
wlogger.warn(`Unauthenticated request rejected: ${req.method} ${req.path}`)
|
|
133
|
+
return res.status(401).json({
|
|
134
|
+
error: 'Unauthorized',
|
|
135
|
+
message: 'Valid Bearer token required in Authorization header'
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
app.use(requireBasicAuthMiddleware)
|
|
90
140
|
} else {
|
|
91
|
-
|
|
141
|
+
// X402_ENABLED=false AND USE_BASIC_AUTH=false: No access control middleware
|
|
142
|
+
wlogger.info('No access control middleware enabled')
|
|
92
143
|
}
|
|
93
144
|
|
|
94
145
|
// Endpoint logging middleware
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "psf-bch-api",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"main": "
|
|
3
|
+
"version": "7.1.0",
|
|
4
|
+
"main": "psf-bch-api.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"start": "node bin/server.js",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"dotenv": "16.3.1",
|
|
22
22
|
"express": "5.1.0",
|
|
23
23
|
"minimal-slp-wallet": "5.13.3",
|
|
24
|
+
"psffpp": "1.2.0",
|
|
24
25
|
"slp-token-media": "1.2.10",
|
|
25
26
|
"winston": "3.11.0",
|
|
26
27
|
"winston-daily-rotate-file": "4.7.1",
|
package/src/config/env/common.js
CHANGED
|
@@ -34,10 +34,15 @@ const priceSat = Number.isFinite(parsedPriceSat) && parsedPriceSat > 0 ? parsedP
|
|
|
34
34
|
const x402Defaults = {
|
|
35
35
|
enabled: normalizeBoolean(process.env.X402_ENABLED, true),
|
|
36
36
|
facilitatorUrl: process.env.FACILITATOR_URL || 'http://localhost:4345/facilitator',
|
|
37
|
-
serverAddress: process.env.SERVER_BCH_ADDRESS || 'bitcoincash:
|
|
37
|
+
serverAddress: process.env.SERVER_BCH_ADDRESS || 'bitcoincash:qqsrke9lh257tqen99dkyy2emh4uty0vky9y0z0lsr',
|
|
38
38
|
priceSat
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const basicAuthDefaults = {
|
|
42
|
+
enabled: normalizeBoolean(process.env.USE_BASIC_AUTH, false),
|
|
43
|
+
token: process.env.BASIC_AUTH_TOKEN || ''
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
export default {
|
|
42
47
|
// Server port
|
|
43
48
|
port: process.env.PORT || 5942,
|
|
@@ -73,13 +78,15 @@ export default {
|
|
|
73
78
|
},
|
|
74
79
|
|
|
75
80
|
// REST API URL for wallet operations
|
|
76
|
-
restURL: process.env.REST_URL || process.env.LOCAL_RESTURL || 'http://127.0.0.1:
|
|
81
|
+
restURL: process.env.REST_URL || process.env.LOCAL_RESTURL || 'http://127.0.0.1:5942/v6/',
|
|
77
82
|
|
|
78
83
|
// IPFS Gateway URL
|
|
79
84
|
ipfsGateway: process.env.IPFS_GATEWAY || 'p2wdb-gateway-678.fullstack.cash',
|
|
80
85
|
|
|
81
86
|
x402: x402Defaults,
|
|
82
87
|
|
|
88
|
+
basicAuth: basicAuthDefaults,
|
|
89
|
+
|
|
83
90
|
// Version
|
|
84
91
|
version
|
|
85
92
|
}
|
package/src/config/x402.js
CHANGED
|
@@ -12,6 +12,7 @@ import ControlRouter from './full-node/control/router.js'
|
|
|
12
12
|
import DSProofRouter from './full-node/dsproof/router.js'
|
|
13
13
|
import FulcrumRouter from './fulcrum/router.js'
|
|
14
14
|
import MiningRouter from './full-node/mining/router.js'
|
|
15
|
+
import PriceRouter from './price/router.js'
|
|
15
16
|
import RawTransactionsRouter from './full-node/rawtransactions/router.js'
|
|
16
17
|
import SlpRouter from './slp/router.js'
|
|
17
18
|
import config from '../../config/index.js'
|
|
@@ -75,6 +76,9 @@ class RESTControllers {
|
|
|
75
76
|
const miningRouter = new MiningRouter(dependencies)
|
|
76
77
|
miningRouter.attach(app)
|
|
77
78
|
|
|
79
|
+
const priceRouter = new PriceRouter(dependencies)
|
|
80
|
+
priceRouter.attach(app)
|
|
81
|
+
|
|
78
82
|
const rawtransactionsRouter = new RawTransactionsRouter(dependencies)
|
|
79
83
|
rawtransactionsRouter.attach(app)
|
|
80
84
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/*
|
|
2
|
+
REST API Controller for the /price routes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import wlogger from '../../../adapters/wlogger.js'
|
|
6
|
+
|
|
7
|
+
class PriceRESTController {
|
|
8
|
+
constructor (localConfig = {}) {
|
|
9
|
+
this.adapters = localConfig.adapters
|
|
10
|
+
if (!this.adapters) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'Instance of Adapters library required when instantiating Price REST Controller.'
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
this.useCases = localConfig.useCases
|
|
17
|
+
if (!this.useCases || !this.useCases.price) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'Instance of Price use cases required when instantiating Price REST Controller.'
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
this.priceUseCases = this.useCases.price
|
|
24
|
+
|
|
25
|
+
// Bind functions
|
|
26
|
+
this.root = this.root.bind(this)
|
|
27
|
+
this.getBCHUSD = this.getBCHUSD.bind(this)
|
|
28
|
+
this.getPsffppWritePrice = this.getPsffppWritePrice.bind(this)
|
|
29
|
+
this.handleError = this.handleError.bind(this)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @api {get} /v6/price/ Service status
|
|
34
|
+
* @apiName PriceRoot
|
|
35
|
+
* @apiGroup Price
|
|
36
|
+
*
|
|
37
|
+
* @apiDescription Returns the status of the price service.
|
|
38
|
+
*
|
|
39
|
+
* @apiSuccess {String} status Service identifier
|
|
40
|
+
*/
|
|
41
|
+
async root (req, res) {
|
|
42
|
+
return res.status(200).json({ status: 'price' })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @api {get} /v6/price/bchusd Get the USD price of BCH
|
|
47
|
+
* @apiName GetBCHUSD
|
|
48
|
+
* @apiGroup Price
|
|
49
|
+
* @apiDescription Get the USD price of BCH from Coinex.
|
|
50
|
+
*
|
|
51
|
+
* @apiExample Example usage:
|
|
52
|
+
* curl -X GET "https://api.fullstack.cash/v6/price/bchusd" -H "accept: application/json"
|
|
53
|
+
*
|
|
54
|
+
* @apiSuccess {Number} usd The USD price of BCH
|
|
55
|
+
*/
|
|
56
|
+
async getBCHUSD (req, res) {
|
|
57
|
+
try {
|
|
58
|
+
const price = await this.priceUseCases.getBCHUSD()
|
|
59
|
+
return res.status(200).json({ usd: price })
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return this.handleError(err, res)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @api {get} /v6/price/psffpp Get the PSF price for writing to the PSFFPP
|
|
67
|
+
* @apiName GetPsffppWritePrice
|
|
68
|
+
* @apiGroup Price
|
|
69
|
+
* @apiDescription Get the price to pin 1MB of content to the PSFFPP pinning
|
|
70
|
+
* network on IPFS. The price is denominated in PSF tokens.
|
|
71
|
+
*
|
|
72
|
+
* @apiExample Example usage:
|
|
73
|
+
* curl -X GET "https://api.fullstack.cash/v6/price/psffpp" -H "accept: application/json"
|
|
74
|
+
*
|
|
75
|
+
* @apiSuccess {Number} writePrice The price in PSF tokens to write 1MB to PSFFPP
|
|
76
|
+
*/
|
|
77
|
+
async getPsffppWritePrice (req, res) {
|
|
78
|
+
try {
|
|
79
|
+
const writePrice = await this.priceUseCases.getPsffppWritePrice()
|
|
80
|
+
return res.status(200).json({ writePrice })
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return this.handleError(err, res)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
handleError (err, res) {
|
|
87
|
+
wlogger.error('Error in PriceRESTController:', err)
|
|
88
|
+
|
|
89
|
+
const status = err.status || 500
|
|
90
|
+
const message = err.message || 'Internal server error'
|
|
91
|
+
|
|
92
|
+
return res.status(status).json({ error: message })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default PriceRESTController
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/*
|
|
2
|
+
REST API router for /price routes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import express from 'express'
|
|
6
|
+
import PriceRESTController from './controller.js'
|
|
7
|
+
|
|
8
|
+
class PriceRouter {
|
|
9
|
+
constructor (localConfig = {}) {
|
|
10
|
+
this.adapters = localConfig.adapters
|
|
11
|
+
if (!this.adapters) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
'Instance of Adapters library required when instantiating Price REST Router.'
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
this.useCases = localConfig.useCases
|
|
18
|
+
if (!this.useCases) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'Instance of Use Cases library required when instantiating Price REST Router.'
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const dependencies = {
|
|
25
|
+
adapters: this.adapters,
|
|
26
|
+
useCases: this.useCases
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.priceController = new PriceRESTController(dependencies)
|
|
30
|
+
|
|
31
|
+
this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '')
|
|
32
|
+
this.baseUrl = `${this.apiPrefix}/price`
|
|
33
|
+
if (!this.baseUrl.startsWith('/')) {
|
|
34
|
+
this.baseUrl = `/${this.baseUrl}`
|
|
35
|
+
}
|
|
36
|
+
this.router = express.Router()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
attach (app) {
|
|
40
|
+
if (!app) {
|
|
41
|
+
throw new Error('Must pass app object when attaching REST API controllers.')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.router.get('/', this.priceController.root)
|
|
45
|
+
this.router.get('/bchusd', this.priceController.getBCHUSD)
|
|
46
|
+
this.router.get('/psffpp', this.priceController.getPsffppWritePrice)
|
|
47
|
+
|
|
48
|
+
app.use(this.baseUrl, this.router)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default PriceRouter
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Basic Authentication Middleware
|
|
3
|
+
|
|
4
|
+
This middleware validates Bearer tokens from the Authorization header.
|
|
5
|
+
When a valid token is provided, it sets req.locals.basicAuthValid = true
|
|
6
|
+
to allow bypassing x402 middleware.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import config from '../config/index.js'
|
|
10
|
+
import wlogger from '../adapters/wlogger.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Middleware function that validates Bearer token authentication
|
|
14
|
+
* @param {Object} req - Express request object
|
|
15
|
+
* @param {Object} res - Express response object
|
|
16
|
+
* @param {Function} next - Express next middleware function
|
|
17
|
+
*/
|
|
18
|
+
export function basicAuthMiddleware (req, res, next) {
|
|
19
|
+
// Initialize req.locals if it doesn't exist
|
|
20
|
+
if (!req.locals) {
|
|
21
|
+
req.locals = {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Default to false
|
|
25
|
+
req.locals.basicAuthValid = false
|
|
26
|
+
|
|
27
|
+
// Get the configured token
|
|
28
|
+
const configuredToken = config.basicAuth?.token
|
|
29
|
+
|
|
30
|
+
// If no token is configured, skip validation
|
|
31
|
+
if (!configuredToken) {
|
|
32
|
+
wlogger.warn('Basic auth enabled but no BASIC_AUTH_TOKEN configured')
|
|
33
|
+
return next()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get the Authorization header
|
|
37
|
+
const authHeader = req.headers.authorization
|
|
38
|
+
|
|
39
|
+
// If no Authorization header, continue (x402 will handle unauthorized requests)
|
|
40
|
+
if (!authHeader) {
|
|
41
|
+
return next()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check if it's a Bearer token
|
|
45
|
+
const parts = authHeader.split(' ')
|
|
46
|
+
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
|
47
|
+
return next()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const providedToken = parts[1]
|
|
51
|
+
|
|
52
|
+
// Compare tokens
|
|
53
|
+
if (providedToken === configuredToken) {
|
|
54
|
+
req.locals.basicAuthValid = true
|
|
55
|
+
wlogger.verbose(`Basic auth validated for request to ${req.path}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Always continue to next middleware
|
|
59
|
+
// If auth failed, x402 middleware will handle the request
|
|
60
|
+
next()
|
|
61
|
+
}
|
package/src/use-cases/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import ControlUseCases from './full-node-control-use-cases.js'
|
|
|
10
10
|
import DSProofUseCases from './full-node-dsproof-use-cases.js'
|
|
11
11
|
import FulcrumUseCases from './fulcrum-use-cases.js'
|
|
12
12
|
import MiningUseCases from './full-node-mining-use-cases.js'
|
|
13
|
+
import PriceUseCases from './price-use-cases.js'
|
|
13
14
|
import RawTransactionsUseCases from './full-node-rawtransactions-use-cases.js'
|
|
14
15
|
import SlpUseCases from './slp-use-cases.js'
|
|
15
16
|
|
|
@@ -27,6 +28,7 @@ class UseCases {
|
|
|
27
28
|
this.dsproof = new DSProofUseCases({ adapters: this.adapters })
|
|
28
29
|
this.fulcrum = new FulcrumUseCases({ adapters: this.adapters })
|
|
29
30
|
this.mining = new MiningUseCases({ adapters: this.adapters })
|
|
31
|
+
this.price = new PriceUseCases({ adapters: this.adapters })
|
|
30
32
|
this.rawtransactions = new RawTransactionsUseCases({ adapters: this.adapters })
|
|
31
33
|
this.slp = new SlpUseCases({ adapters: this.adapters })
|
|
32
34
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Use cases for price-related operations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Global npm libraries
|
|
6
|
+
import axios from 'axios'
|
|
7
|
+
import SlpWallet from 'minimal-slp-wallet'
|
|
8
|
+
import PSFFPP from 'psffpp'
|
|
9
|
+
|
|
10
|
+
// Local libraries
|
|
11
|
+
import wlogger from '../adapters/wlogger.js'
|
|
12
|
+
import config from '../config/index.js'
|
|
13
|
+
|
|
14
|
+
class PriceUseCases {
|
|
15
|
+
constructor (localConfig = {}) {
|
|
16
|
+
this.adapters = localConfig.adapters
|
|
17
|
+
|
|
18
|
+
if (!this.adapters) {
|
|
19
|
+
throw new Error('Adapters instance required when instantiating Price use cases.')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Get config
|
|
23
|
+
this.config = localConfig.config || config
|
|
24
|
+
|
|
25
|
+
// Coinex API URL for BCH/USDT
|
|
26
|
+
this.bchCoinexPriceUrl =
|
|
27
|
+
'https://api.coinex.com/v1/market/ticker?market=bchusdt'
|
|
28
|
+
|
|
29
|
+
// Allow axios to be injected for testing
|
|
30
|
+
this.axios = localConfig.axios || axios
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the USD price of BCH from Coinex.
|
|
35
|
+
* @returns {Promise<number>} The USD price of BCH
|
|
36
|
+
*/
|
|
37
|
+
async getBCHUSD () {
|
|
38
|
+
try {
|
|
39
|
+
// Request options
|
|
40
|
+
const opt = {
|
|
41
|
+
method: 'get',
|
|
42
|
+
baseURL: this.bchCoinexPriceUrl,
|
|
43
|
+
timeout: 15000
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const response = await this.axios.request(opt)
|
|
47
|
+
|
|
48
|
+
const price = Number(response.data.data.ticker.last)
|
|
49
|
+
|
|
50
|
+
return price
|
|
51
|
+
} catch (err) {
|
|
52
|
+
wlogger.error('Error in PriceUseCases.getBCHUSD()', err)
|
|
53
|
+
throw err
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the PSF price for writing to the PSFFPP.
|
|
59
|
+
* Returns the price to pin 1MB of content to the PSFFPP pinning
|
|
60
|
+
* network on IPFS. The price is denominated in PSF tokens.
|
|
61
|
+
* @returns {Promise<number>} The write price in PSF tokens
|
|
62
|
+
*/
|
|
63
|
+
async getPsffppWritePrice () {
|
|
64
|
+
try {
|
|
65
|
+
const wallet = new SlpWallet(undefined, {
|
|
66
|
+
interface: 'rest-api',
|
|
67
|
+
restURL: this.config.restURL
|
|
68
|
+
})
|
|
69
|
+
await wallet.walletInfoPromise
|
|
70
|
+
|
|
71
|
+
const psffpp = new PSFFPP({ wallet })
|
|
72
|
+
|
|
73
|
+
const writePrice = await psffpp.getMcWritePrice()
|
|
74
|
+
|
|
75
|
+
return writePrice
|
|
76
|
+
} catch (err) {
|
|
77
|
+
wlogger.error('Error in PriceUseCases.getPsffppWritePrice()', err)
|
|
78
|
+
throw err
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default PriceUseCases
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Unit tests for PriceRESTController.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { assert } from 'chai'
|
|
6
|
+
import sinon from 'sinon'
|
|
7
|
+
|
|
8
|
+
import PriceRESTController from '../../../src/controllers/rest-api/price/controller.js'
|
|
9
|
+
import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js'
|
|
10
|
+
|
|
11
|
+
describe('#price-controller.js', () => {
|
|
12
|
+
let sandbox
|
|
13
|
+
let mockAdapters
|
|
14
|
+
let mockUseCases
|
|
15
|
+
let uut
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
sandbox = sinon.createSandbox()
|
|
19
|
+
mockAdapters = {}
|
|
20
|
+
mockUseCases = {
|
|
21
|
+
price: {
|
|
22
|
+
getBCHUSD: sandbox.stub().resolves(250.5),
|
|
23
|
+
getPsffppWritePrice: sandbox.stub().resolves(0.08335233)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
uut = new PriceRESTController({
|
|
28
|
+
adapters: mockAdapters,
|
|
29
|
+
useCases: mockUseCases
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
sandbox.restore()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('#constructor()', () => {
|
|
38
|
+
it('should require adapters', () => {
|
|
39
|
+
assert.throws(() => {
|
|
40
|
+
// eslint-disable-next-line no-new
|
|
41
|
+
new PriceRESTController({ useCases: mockUseCases })
|
|
42
|
+
}, /Adapters library required/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should require price use cases', () => {
|
|
46
|
+
assert.throws(() => {
|
|
47
|
+
// eslint-disable-next-line no-new
|
|
48
|
+
new PriceRESTController({ adapters: mockAdapters, useCases: {} })
|
|
49
|
+
}, /Price use cases required/)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('#root()', () => {
|
|
54
|
+
it('should return price status', async () => {
|
|
55
|
+
const req = createMockRequest()
|
|
56
|
+
const res = createMockResponse()
|
|
57
|
+
|
|
58
|
+
await uut.root(req, res)
|
|
59
|
+
|
|
60
|
+
assert.equal(res.statusValue, 200)
|
|
61
|
+
assert.deepEqual(res.jsonData, { status: 'price' })
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('#getBCHUSD()', () => {
|
|
66
|
+
it('should return BCH USD price on success', async () => {
|
|
67
|
+
const req = createMockRequest()
|
|
68
|
+
const res = createMockResponse()
|
|
69
|
+
|
|
70
|
+
await uut.getBCHUSD(req, res)
|
|
71
|
+
|
|
72
|
+
assert.equal(res.statusValue, 200)
|
|
73
|
+
assert.deepEqual(res.jsonData, { usd: 250.5 })
|
|
74
|
+
assert.isTrue(mockUseCases.price.getBCHUSD.calledOnce)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should handle errors via handleError', async () => {
|
|
78
|
+
const error = new Error('API failure')
|
|
79
|
+
error.status = 503
|
|
80
|
+
mockUseCases.price.getBCHUSD.rejects(error)
|
|
81
|
+
const req = createMockRequest()
|
|
82
|
+
const res = createMockResponse()
|
|
83
|
+
|
|
84
|
+
await uut.getBCHUSD(req, res)
|
|
85
|
+
|
|
86
|
+
assert.equal(res.statusValue, 503)
|
|
87
|
+
assert.deepEqual(res.jsonData, { error: 'API failure' })
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('#getPsffppWritePrice()', () => {
|
|
92
|
+
it('should return PSFFPP write price on success', async () => {
|
|
93
|
+
const req = createMockRequest()
|
|
94
|
+
const res = createMockResponse()
|
|
95
|
+
|
|
96
|
+
await uut.getPsffppWritePrice(req, res)
|
|
97
|
+
|
|
98
|
+
assert.equal(res.statusValue, 200)
|
|
99
|
+
assert.deepEqual(res.jsonData, { writePrice: 0.08335233 })
|
|
100
|
+
assert.isTrue(mockUseCases.price.getPsffppWritePrice.calledOnce)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should handle errors via handleError', async () => {
|
|
104
|
+
const error = new Error('PSFFPP failure')
|
|
105
|
+
error.status = 500
|
|
106
|
+
mockUseCases.price.getPsffppWritePrice.rejects(error)
|
|
107
|
+
const req = createMockRequest()
|
|
108
|
+
const res = createMockResponse()
|
|
109
|
+
|
|
110
|
+
await uut.getPsffppWritePrice(req, res)
|
|
111
|
+
|
|
112
|
+
assert.equal(res.statusValue, 500)
|
|
113
|
+
assert.deepEqual(res.jsonData, { error: 'PSFFPP failure' })
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -10,6 +10,7 @@ import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockc
|
|
|
10
10
|
import ControlRouter from '../../../src/controllers/rest-api/full-node/control/router.js'
|
|
11
11
|
import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/router.js'
|
|
12
12
|
import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/router.js'
|
|
13
|
+
import PriceRouter from '../../../src/controllers/rest-api/price/router.js'
|
|
13
14
|
import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/router.js'
|
|
14
15
|
import FulcrumRouter from '../../../src/controllers/rest-api/fulcrum/router.js'
|
|
15
16
|
import SlpRouter from '../../../src/controllers/rest-api/slp/router.js'
|
|
@@ -75,6 +76,10 @@ describe('#controllers/rest-api/index.js', () => {
|
|
|
75
76
|
getMiningInfo: () => {},
|
|
76
77
|
getNetworkHashPS: () => {}
|
|
77
78
|
},
|
|
79
|
+
price: {
|
|
80
|
+
getBCHUSD: () => {},
|
|
81
|
+
getPsffppWritePrice: () => {}
|
|
82
|
+
},
|
|
78
83
|
rawtransactions: {
|
|
79
84
|
decodeRawTransaction: () => {},
|
|
80
85
|
decodeRawTransactions: () => {},
|
|
@@ -126,6 +131,7 @@ describe('#controllers/rest-api/index.js', () => {
|
|
|
126
131
|
const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach')
|
|
127
132
|
const fulcrumAttachStub = sandbox.stub(FulcrumRouter.prototype, 'attach')
|
|
128
133
|
const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach')
|
|
134
|
+
const priceAttachStub = sandbox.stub(PriceRouter.prototype, 'attach')
|
|
129
135
|
const rawtransactionsAttachStub = sandbox.stub(RawTransactionsRouter.prototype, 'attach')
|
|
130
136
|
const slpAttachStub = sandbox.stub(SlpRouter.prototype, 'attach')
|
|
131
137
|
const restControllers = new RESTControllers({
|
|
@@ -146,6 +152,8 @@ describe('#controllers/rest-api/index.js', () => {
|
|
|
146
152
|
assert.equal(fulcrumAttachStub.getCall(0).args[0], app)
|
|
147
153
|
assert.isTrue(miningAttachStub.calledOnce)
|
|
148
154
|
assert.equal(miningAttachStub.getCall(0).args[0], app)
|
|
155
|
+
assert.isTrue(priceAttachStub.calledOnce)
|
|
156
|
+
assert.equal(priceAttachStub.getCall(0).args[0], app)
|
|
149
157
|
assert.isTrue(rawtransactionsAttachStub.calledOnce)
|
|
150
158
|
assert.equal(rawtransactionsAttachStub.getCall(0).args[0], app)
|
|
151
159
|
assert.isTrue(slpAttachStub.calledOnce)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Unit tests for PriceUseCases.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { assert } from 'chai'
|
|
6
|
+
import sinon from 'sinon'
|
|
7
|
+
|
|
8
|
+
import PriceUseCases from '../../../src/use-cases/price-use-cases.js'
|
|
9
|
+
|
|
10
|
+
describe('#price-use-cases.js', () => {
|
|
11
|
+
let sandbox
|
|
12
|
+
let mockAdapters
|
|
13
|
+
let mockAxios
|
|
14
|
+
let mockConfig
|
|
15
|
+
let uut
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
sandbox = sinon.createSandbox()
|
|
19
|
+
mockAdapters = {}
|
|
20
|
+
|
|
21
|
+
mockConfig = {
|
|
22
|
+
restURL: 'http://localhost:3000/v5/'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Mock axios
|
|
26
|
+
mockAxios = {
|
|
27
|
+
request: sandbox.stub()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
uut = new PriceUseCases({
|
|
31
|
+
adapters: mockAdapters,
|
|
32
|
+
axios: mockAxios,
|
|
33
|
+
config: mockConfig
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
sandbox.restore()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('#constructor()', () => {
|
|
42
|
+
it('should require adapters', () => {
|
|
43
|
+
assert.throws(() => {
|
|
44
|
+
// eslint-disable-next-line no-new
|
|
45
|
+
new PriceUseCases()
|
|
46
|
+
}, /Adapters instance required/)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('#getBCHUSD()', () => {
|
|
51
|
+
it('should return BCH price from Coinex API', async () => {
|
|
52
|
+
const mockPrice = 250.5
|
|
53
|
+
mockAxios.request.resolves({
|
|
54
|
+
data: {
|
|
55
|
+
data: {
|
|
56
|
+
ticker: {
|
|
57
|
+
last: mockPrice.toString()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const result = await uut.getBCHUSD()
|
|
64
|
+
|
|
65
|
+
assert.equal(result, mockPrice)
|
|
66
|
+
assert.isTrue(mockAxios.request.calledOnce)
|
|
67
|
+
const callArgs = mockAxios.request.getCall(0).args[0]
|
|
68
|
+
assert.equal(callArgs.method, 'get')
|
|
69
|
+
assert.equal(callArgs.baseURL, 'https://api.coinex.com/v1/market/ticker?market=bchusdt')
|
|
70
|
+
assert.equal(callArgs.timeout, 15000)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should handle errors', async () => {
|
|
74
|
+
const error = new Error('API error')
|
|
75
|
+
mockAxios.request.rejects(error)
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await uut.getBCHUSD()
|
|
79
|
+
assert.fail('Should have thrown an error')
|
|
80
|
+
} catch (err) {
|
|
81
|
+
assert.equal(err.message, 'API error')
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('#getPsffppWritePrice()', () => {
|
|
87
|
+
it('should handle errors properly', async () => {
|
|
88
|
+
// Note: Full unit testing of getPsffppWritePrice is difficult due to dynamic imports
|
|
89
|
+
// of SlpWallet and PSFFPP. Integration tests should verify the full flow.
|
|
90
|
+
// This test verifies that errors are properly handled and propagated.
|
|
91
|
+
try {
|
|
92
|
+
// This will likely fail in unit test environment without proper setup
|
|
93
|
+
// but we verify error handling works correctly
|
|
94
|
+
await uut.getPsffppWritePrice()
|
|
95
|
+
// If it succeeds, that's also acceptable
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// Verify error is properly formatted
|
|
98
|
+
assert.isTrue(err instanceof Error)
|
|
99
|
+
// Verify error was logged (indirectly through wlogger)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
})
|
|
File without changes
|