psf-bch-api 1.2.0 → 1.3.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 +9 -0
- package/bin/server.js +2 -1
- package/package.json +4 -1
- package/src/adapters/fulcrum-api.js +124 -0
- package/src/adapters/full-node-rpc.js +2 -6
- package/src/adapters/index.js +4 -0
- package/src/adapters/slp-indexer-api.js +124 -0
- package/src/config/env/common.js +21 -24
- package/src/controllers/rest-api/fulcrum/controller.js +563 -0
- package/src/controllers/rest-api/fulcrum/router.js +64 -0
- package/src/controllers/rest-api/full-node/blockchain/controller.js +4 -4
- package/src/controllers/rest-api/full-node/mining/controller.js +99 -0
- package/src/controllers/rest-api/full-node/mining/router.js +52 -0
- package/src/controllers/rest-api/full-node/rawtransactions/controller.js +333 -0
- package/src/controllers/rest-api/full-node/rawtransactions/router.js +58 -0
- package/src/controllers/rest-api/index.js +19 -3
- package/src/controllers/rest-api/slp/controller.js +218 -0
- package/src/controllers/rest-api/slp/router.js +55 -0
- package/src/controllers/timer-controller.js +1 -1
- package/src/use-cases/fulcrum-use-cases.js +155 -0
- package/src/use-cases/full-node-mining-use-cases.js +28 -0
- package/src/use-cases/full-node-rawtransactions-use-cases.js +121 -0
- package/src/use-cases/index.js +8 -0
- package/src/use-cases/slp-use-cases.js +321 -0
- package/test/unit/controllers/blockchain-controller-unit.js +2 -3
- package/test/unit/controllers/fulcrum-controller-unit.js +481 -0
- package/test/unit/controllers/mining-controller-unit.js +139 -0
- package/test/unit/controllers/rawtransactions-controller-unit.js +388 -0
- package/test/unit/controllers/rest-api-index-unit.js +59 -3
- package/test/unit/controllers/slp-controller-unit.js +312 -0
- package/test/unit/use-cases/fulcrum-use-cases-unit.js +297 -0
- package/test/unit/use-cases/full-node-mining-use-cases-unit.js +84 -0
- package/test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js +267 -0
- package/test/unit/use-cases/slp-use-cases-unit.js +296 -0
- package/src/entities/event.js +0 -71
- package/test/integration/api/event-integration.js +0 -250
- package/test/integration/api/req-integration.js +0 -173
- package/test/integration/api/subscription-integration.js +0 -198
- package/test/integration/use-cases/manage-subscription-integration.js +0 -163
- package/test/integration/use-cases/publish-event-integration.js +0 -104
- package/test/integration/use-cases/query-events-integration.js +0 -95
- package/test/unit/entities/event-unit.js +0 -139
- /package/src/controllers/rest-api/full-node/blockchain/{index.js → router.js} +0 -0
- /package/src/controllers/rest-api/full-node/control/{index.js → router.js} +0 -0
- /package/src/controllers/rest-api/full-node/dsproof/{index.js → router.js} +0 -0
package/.env-local
CHANGED
|
@@ -2,3 +2,12 @@
|
|
|
2
2
|
RPC_BASEURL=http://172.17.0.1:8332
|
|
3
3
|
RPC_USERNAME=bitcoin
|
|
4
4
|
RPC_PASSWORD=password
|
|
5
|
+
|
|
6
|
+
# x402 payments required to access this API?
|
|
7
|
+
X402_ENABLED=false
|
|
8
|
+
|
|
9
|
+
# Fulcrum Indexer
|
|
10
|
+
FULCRUM_API=http://192.168.2.127:3001
|
|
11
|
+
|
|
12
|
+
# SLP Indexer
|
|
13
|
+
SLP_INDEXER_API=http://192.168.2.127:5010
|
package/bin/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
Express server for
|
|
2
|
+
Express server for psf-bch-api REST API.
|
|
3
3
|
The architecture of the code follows the Clean Architecture pattern.
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -72,6 +72,7 @@ class Server {
|
|
|
72
72
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
|
|
73
73
|
}))
|
|
74
74
|
|
|
75
|
+
// Wrap all endpoints in x402 middleware. This handles payments for the API calls.
|
|
75
76
|
if (x402Settings.enabled) {
|
|
76
77
|
const routes = buildX402Routes(this.config.apiPrefix)
|
|
77
78
|
const facilitatorOptions = x402Settings.facilitatorUrl
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "psf-bch-api",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"description": "REST API proxy to Bitcoin Cash infrastructure",
|
|
17
17
|
"dependencies": {
|
|
18
|
+
"@psf/bch-js": "6.8.3",
|
|
18
19
|
"axios": "1.7.7",
|
|
19
20
|
"cors": "2.8.5",
|
|
20
21
|
"dotenv": "16.3.1",
|
|
21
22
|
"express": "5.1.0",
|
|
23
|
+
"minimal-slp-wallet": "5.13.3",
|
|
24
|
+
"slp-token-media": "1.2.10",
|
|
22
25
|
"winston": "3.11.0",
|
|
23
26
|
"winston-daily-rotate-file": "4.7.1",
|
|
24
27
|
"x402-bch-express": "1.1.1"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Adapter library for interacting with Fulcrum API service over HTTP.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import axios from 'axios'
|
|
6
|
+
import wlogger from './wlogger.js'
|
|
7
|
+
import config from '../config/index.js'
|
|
8
|
+
|
|
9
|
+
class FulcrumAPIAdapter {
|
|
10
|
+
constructor (localConfig = {}) {
|
|
11
|
+
this.config = localConfig.config || config
|
|
12
|
+
|
|
13
|
+
// Allow missing config for testing environments
|
|
14
|
+
if (!this.config.fulcrumApi || !this.config.fulcrumApi.baseUrl) {
|
|
15
|
+
if (process.env.NODE_ENV === 'test' || process.env.TEST) {
|
|
16
|
+
// In test environment, create a mock baseURL
|
|
17
|
+
this.config.fulcrumApi = {
|
|
18
|
+
baseUrl: 'http://localhost:50001',
|
|
19
|
+
timeoutMs: 15000
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
throw new Error('FULCRUM_API env var not set. Can not connect to Fulcrum indexer.')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
baseUrl,
|
|
28
|
+
timeoutMs = 15000
|
|
29
|
+
} = this.config.fulcrumApi
|
|
30
|
+
|
|
31
|
+
this.http = axios.create({
|
|
32
|
+
baseURL: baseUrl,
|
|
33
|
+
timeout: timeoutMs
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async get (path) {
|
|
38
|
+
try {
|
|
39
|
+
const response = await this.http.get(path)
|
|
40
|
+
return response.data
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw this._handleError(err)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async post (path, data) {
|
|
47
|
+
try {
|
|
48
|
+
const response = await this.http.post(path, data)
|
|
49
|
+
return response.data
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw this._handleError(err)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_handleError (err) {
|
|
56
|
+
const { status, message } = this.decodeError(err)
|
|
57
|
+
const error = new Error(message)
|
|
58
|
+
error.status = status
|
|
59
|
+
error.originalError = err
|
|
60
|
+
return error
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
decodeError (err) {
|
|
64
|
+
try {
|
|
65
|
+
// Attempt to extract error message from response data
|
|
66
|
+
if (err.response && err.response.data) {
|
|
67
|
+
const data = err.response.data
|
|
68
|
+
// Handle structured error responses
|
|
69
|
+
if (data.error) {
|
|
70
|
+
return this._formatError(data.error, err.response.status || 400)
|
|
71
|
+
}
|
|
72
|
+
// Handle string error messages
|
|
73
|
+
if (typeof data === 'string') {
|
|
74
|
+
return this._formatError(data, err.response.status || 400)
|
|
75
|
+
}
|
|
76
|
+
// Handle object responses that might contain error info
|
|
77
|
+
if (typeof data === 'object' && data.message) {
|
|
78
|
+
return this._formatError(data.message, err.response.status || 400)
|
|
79
|
+
}
|
|
80
|
+
// Fallback to returning the status
|
|
81
|
+
return this._formatError('Fulcrum API error', err.response.status || 500)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Network errors
|
|
85
|
+
if (err.message) {
|
|
86
|
+
if (err.message.includes('ENOTFOUND') || err.message.includes('ENETUNREACH') || err.message.includes('EAI_AGAIN')) {
|
|
87
|
+
return this._formatError(
|
|
88
|
+
'Network error: Could not communicate with Fulcrum API service.',
|
|
89
|
+
503
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (err.code && (err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')) {
|
|
95
|
+
return this._formatError(
|
|
96
|
+
'Network error: Could not communicate with Fulcrum API service.',
|
|
97
|
+
503
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (err.error && typeof err.error === 'string' && err.error.includes('429')) {
|
|
102
|
+
return this._formatError('429 Too Many Requests', 429)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (err.message) {
|
|
106
|
+
return this._formatError(err.message, err.status || 422)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return this._formatError('Unhandled Fulcrum API error', 500)
|
|
110
|
+
} catch (decodeError) {
|
|
111
|
+
wlogger.error('Unhandled error in FulcrumAPIAdapter.decodeError()', decodeError)
|
|
112
|
+
return this._formatError('Internal server error', 500)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_formatError (message, status = 500) {
|
|
117
|
+
return {
|
|
118
|
+
message: message || 'Internal server error',
|
|
119
|
+
status: status || 500
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default FulcrumAPIAdapter
|
|
@@ -113,12 +113,8 @@ class FullNodeRPCAdapter {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
validateArraySize (length
|
|
117
|
-
const
|
|
118
|
-
const freemiumLimit = Number(this.config.fullNode?.freemiumArrayLimit || 20)
|
|
119
|
-
const proLimit = Number(this.config.fullNode?.proArrayLimit || freemiumLimit)
|
|
120
|
-
|
|
121
|
-
const limit = isProUser ? proLimit : freemiumLimit
|
|
116
|
+
validateArraySize (length) {
|
|
117
|
+
const limit = 20
|
|
122
118
|
return length <= limit
|
|
123
119
|
}
|
|
124
120
|
|
package/src/adapters/index.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// Load individual adapter libraries.
|
|
8
8
|
// import NostrRelayAdapter from './nostr-relay.js'
|
|
9
9
|
import FullNodeRPCAdapter from './full-node-rpc.js'
|
|
10
|
+
import FulcrumAPIAdapter from './fulcrum-api.js'
|
|
11
|
+
import SlpIndexerAPIAdapter from './slp-indexer-api.js'
|
|
10
12
|
import config from '../config/index.js'
|
|
11
13
|
|
|
12
14
|
class Adapters {
|
|
@@ -33,6 +35,8 @@ class Adapters {
|
|
|
33
35
|
// this.nostrRelay = this.nostrRelays[0]
|
|
34
36
|
|
|
35
37
|
this.fullNode = new FullNodeRPCAdapter({ config: this.config })
|
|
38
|
+
this.fulcrum = new FulcrumAPIAdapter({ config: this.config })
|
|
39
|
+
this.slpIndexer = new SlpIndexerAPIAdapter({ config: this.config })
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
async start () {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Adapter library for interacting with SLP Indexer API service over HTTP.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import axios from 'axios'
|
|
6
|
+
import wlogger from './wlogger.js'
|
|
7
|
+
import config from '../config/index.js'
|
|
8
|
+
|
|
9
|
+
class SlpIndexerAPIAdapter {
|
|
10
|
+
constructor (localConfig = {}) {
|
|
11
|
+
this.config = localConfig.config || config
|
|
12
|
+
|
|
13
|
+
// Allow missing config for testing environments
|
|
14
|
+
if (!this.config.slpIndexerApi || !this.config.slpIndexerApi.baseUrl) {
|
|
15
|
+
if (process.env.NODE_ENV === 'test' || process.env.TEST) {
|
|
16
|
+
// In test environment, create a mock baseURL
|
|
17
|
+
this.config.slpIndexerApi = {
|
|
18
|
+
baseUrl: 'http://localhost:5021',
|
|
19
|
+
timeoutMs: 15000
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
throw new Error('SLP_INDEXER_API env var not set. Can not connect to PSF SLP indexer.')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
baseUrl,
|
|
28
|
+
timeoutMs = 15000
|
|
29
|
+
} = this.config.slpIndexerApi
|
|
30
|
+
|
|
31
|
+
this.http = axios.create({
|
|
32
|
+
baseURL: baseUrl,
|
|
33
|
+
timeout: timeoutMs
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async get (path) {
|
|
38
|
+
try {
|
|
39
|
+
const response = await this.http.get(path)
|
|
40
|
+
return response.data
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw this._handleError(err)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async post (path, data) {
|
|
47
|
+
try {
|
|
48
|
+
const response = await this.http.post(path, data)
|
|
49
|
+
return response.data
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw this._handleError(err)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_handleError (err) {
|
|
56
|
+
const { status, message } = this.decodeError(err)
|
|
57
|
+
const error = new Error(message)
|
|
58
|
+
error.status = status
|
|
59
|
+
error.originalError = err
|
|
60
|
+
return error
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
decodeError (err) {
|
|
64
|
+
try {
|
|
65
|
+
// Attempt to extract error message from response data
|
|
66
|
+
if (err.response && err.response.data) {
|
|
67
|
+
const data = err.response.data
|
|
68
|
+
// Handle structured error responses
|
|
69
|
+
if (data.error) {
|
|
70
|
+
return this._formatError(data.error, err.response.status || 400)
|
|
71
|
+
}
|
|
72
|
+
// Handle string error messages
|
|
73
|
+
if (typeof data === 'string') {
|
|
74
|
+
return this._formatError(data, err.response.status || 400)
|
|
75
|
+
}
|
|
76
|
+
// Handle object responses that might contain error info
|
|
77
|
+
if (typeof data === 'object' && data.message) {
|
|
78
|
+
return this._formatError(data.message, err.response.status || 400)
|
|
79
|
+
}
|
|
80
|
+
// Fallback to returning the status
|
|
81
|
+
return this._formatError('SLP Indexer API error', err.response.status || 500)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Network errors
|
|
85
|
+
if (err.message) {
|
|
86
|
+
if (err.message.includes('ENOTFOUND') || err.message.includes('ENETUNREACH') || err.message.includes('EAI_AGAIN')) {
|
|
87
|
+
return this._formatError(
|
|
88
|
+
'Network error: Could not communicate with SLP Indexer API service.',
|
|
89
|
+
503
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (err.code && (err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')) {
|
|
95
|
+
return this._formatError(
|
|
96
|
+
'Network error: Could not communicate with SLP Indexer API service.',
|
|
97
|
+
503
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (err.error && typeof err.error === 'string' && err.error.includes('429')) {
|
|
102
|
+
return this._formatError('429 Too Many Requests', 429)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (err.message) {
|
|
106
|
+
return this._formatError(err.message, err.status || 422)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return this._formatError('Unhandled SLP Indexer API error', 500)
|
|
110
|
+
} catch (decodeError) {
|
|
111
|
+
wlogger.error('Unhandled error in SlpIndexerAPIAdapter.decodeError()', decodeError)
|
|
112
|
+
return this._formatError('Internal server error', 500)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_formatError (message, status = 500) {
|
|
117
|
+
return {
|
|
118
|
+
message: message || 'Internal server error',
|
|
119
|
+
status: status || 500
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default SlpIndexerAPIAdapter
|
package/src/config/env/common.js
CHANGED
|
@@ -16,6 +16,7 @@ const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../packag
|
|
|
16
16
|
|
|
17
17
|
const version = pkgInfo.version
|
|
18
18
|
|
|
19
|
+
// This function is used to convert the string input of an environment variable to a boolean value.
|
|
19
20
|
const normalizeBoolean = (value, defaultValue) => {
|
|
20
21
|
if (value === undefined || value === null || value === '') return defaultValue
|
|
21
22
|
|
|
@@ -25,6 +26,8 @@ const normalizeBoolean = (value, defaultValue) => {
|
|
|
25
26
|
return defaultValue
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// By default, the price per API call is 2000 satoshis.
|
|
30
|
+
// But the user can override this value by setting the X402_PRICE_SAT environment variable.
|
|
28
31
|
const parsedPriceSat = Number(process.env.X402_PRICE_SAT)
|
|
29
32
|
const priceSat = Number.isFinite(parsedPriceSat) && parsedPriceSat > 0 ? parsedPriceSat : 2000
|
|
30
33
|
|
|
@@ -48,30 +51,6 @@ export default {
|
|
|
48
51
|
// Logging level
|
|
49
52
|
logLevel: process.env.LOG_LEVEL || 'info',
|
|
50
53
|
|
|
51
|
-
// Nostr relay configuration (array of relay URLs)
|
|
52
|
-
nostrRelayUrls: (() => {
|
|
53
|
-
// Support NOSTR_RELAY_URLS (plural) as comma-separated string or JSON array
|
|
54
|
-
if (process.env.NOSTR_RELAY_URLS) {
|
|
55
|
-
try {
|
|
56
|
-
// Try parsing as JSON array first
|
|
57
|
-
const parsed = JSON.parse(process.env.NOSTR_RELAY_URLS)
|
|
58
|
-
if (Array.isArray(parsed)) {
|
|
59
|
-
return parsed.filter(url => url && typeof url === 'string')
|
|
60
|
-
}
|
|
61
|
-
} catch (e) {
|
|
62
|
-
// Not JSON, treat as comma-separated string
|
|
63
|
-
return process.env.NOSTR_RELAY_URLS.split(',').map(url => url.trim()).filter(url => url.length > 0)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
// Backward compatibility: support NOSTR_RELAY_URL (singular)
|
|
67
|
-
if (process.env.NOSTR_RELAY_URL) {
|
|
68
|
-
return [process.env.NOSTR_RELAY_URL]
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Default
|
|
72
|
-
return ['wss://nostr-relay.psfoundation.info', 'wss://relay.damus.io']
|
|
73
|
-
})(),
|
|
74
|
-
|
|
75
54
|
// Full node RPC configuration
|
|
76
55
|
fullNode: {
|
|
77
56
|
rpcBaseUrl: process.env.RPC_BASEURL || 'http://127.0.0.1:8332',
|
|
@@ -81,6 +60,24 @@ export default {
|
|
|
81
60
|
rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api'
|
|
82
61
|
},
|
|
83
62
|
|
|
63
|
+
// Fulcrum API configuration
|
|
64
|
+
fulcrumApi: {
|
|
65
|
+
baseUrl: process.env.FULCRUM_API || '',
|
|
66
|
+
timeoutMs: Number(process.env.FULCRUM_TIMEOUT_MS || 15000)
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// SLP Indexer API configuration
|
|
70
|
+
slpIndexerApi: {
|
|
71
|
+
baseUrl: process.env.SLP_INDEXER_API || '',
|
|
72
|
+
timeoutMs: Number(process.env.SLP_INDEXER_TIMEOUT_MS || 15000)
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// REST API URL for wallet operations
|
|
76
|
+
restURL: process.env.REST_URL || process.env.LOCAL_RESTURL || 'http://127.0.0.1:3000/v5/',
|
|
77
|
+
|
|
78
|
+
// IPFS Gateway URL
|
|
79
|
+
ipfsGateway: process.env.IPFS_GATEWAY || 'p2wdb-gateway-678.fullstack.cash',
|
|
80
|
+
|
|
84
81
|
x402: x402Defaults,
|
|
85
82
|
|
|
86
83
|
// Version
|