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.
Files changed (45) hide show
  1. package/.env-local +9 -0
  2. package/bin/server.js +2 -1
  3. package/package.json +4 -1
  4. package/src/adapters/fulcrum-api.js +124 -0
  5. package/src/adapters/full-node-rpc.js +2 -6
  6. package/src/adapters/index.js +4 -0
  7. package/src/adapters/slp-indexer-api.js +124 -0
  8. package/src/config/env/common.js +21 -24
  9. package/src/controllers/rest-api/fulcrum/controller.js +563 -0
  10. package/src/controllers/rest-api/fulcrum/router.js +64 -0
  11. package/src/controllers/rest-api/full-node/blockchain/controller.js +4 -4
  12. package/src/controllers/rest-api/full-node/mining/controller.js +99 -0
  13. package/src/controllers/rest-api/full-node/mining/router.js +52 -0
  14. package/src/controllers/rest-api/full-node/rawtransactions/controller.js +333 -0
  15. package/src/controllers/rest-api/full-node/rawtransactions/router.js +58 -0
  16. package/src/controllers/rest-api/index.js +19 -3
  17. package/src/controllers/rest-api/slp/controller.js +218 -0
  18. package/src/controllers/rest-api/slp/router.js +55 -0
  19. package/src/controllers/timer-controller.js +1 -1
  20. package/src/use-cases/fulcrum-use-cases.js +155 -0
  21. package/src/use-cases/full-node-mining-use-cases.js +28 -0
  22. package/src/use-cases/full-node-rawtransactions-use-cases.js +121 -0
  23. package/src/use-cases/index.js +8 -0
  24. package/src/use-cases/slp-use-cases.js +321 -0
  25. package/test/unit/controllers/blockchain-controller-unit.js +2 -3
  26. package/test/unit/controllers/fulcrum-controller-unit.js +481 -0
  27. package/test/unit/controllers/mining-controller-unit.js +139 -0
  28. package/test/unit/controllers/rawtransactions-controller-unit.js +388 -0
  29. package/test/unit/controllers/rest-api-index-unit.js +59 -3
  30. package/test/unit/controllers/slp-controller-unit.js +312 -0
  31. package/test/unit/use-cases/fulcrum-use-cases-unit.js +297 -0
  32. package/test/unit/use-cases/full-node-mining-use-cases-unit.js +84 -0
  33. package/test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js +267 -0
  34. package/test/unit/use-cases/slp-use-cases-unit.js +296 -0
  35. package/src/entities/event.js +0 -71
  36. package/test/integration/api/event-integration.js +0 -250
  37. package/test/integration/api/req-integration.js +0 -173
  38. package/test/integration/api/subscription-integration.js +0 -198
  39. package/test/integration/use-cases/manage-subscription-integration.js +0 -163
  40. package/test/integration/use-cases/publish-event-integration.js +0 -104
  41. package/test/integration/use-cases/query-events-integration.js +0 -95
  42. package/test/unit/entities/event-unit.js +0 -139
  43. /package/src/controllers/rest-api/full-node/blockchain/{index.js → router.js} +0 -0
  44. /package/src/controllers/rest-api/full-node/control/{index.js → router.js} +0 -0
  45. /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 REST2NOSTR Proxy API.
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.2.0",
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, options = {}) {
117
- const { isProUser = false } = options
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
 
@@ -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
@@ -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