psf-bch-api 1.1.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 (56) hide show
  1. package/.env-local +9 -0
  2. package/README.md +22 -0
  3. package/bin/server.js +24 -1
  4. package/package.json +6 -2
  5. package/src/adapters/fulcrum-api.js +124 -0
  6. package/src/adapters/full-node-rpc.js +2 -6
  7. package/src/adapters/index.js +4 -0
  8. package/src/adapters/slp-indexer-api.js +124 -0
  9. package/src/config/env/common.js +45 -24
  10. package/src/config/x402.js +43 -0
  11. package/src/controllers/index.js +3 -1
  12. package/src/controllers/rest-api/fulcrum/controller.js +563 -0
  13. package/src/controllers/rest-api/fulcrum/router.js +64 -0
  14. package/src/controllers/rest-api/full-node/blockchain/controller.js +26 -26
  15. package/src/controllers/rest-api/full-node/blockchain/{index.js → router.js} +5 -1
  16. package/src/controllers/rest-api/full-node/control/controller.js +68 -0
  17. package/src/controllers/rest-api/full-node/control/router.js +51 -0
  18. package/src/controllers/rest-api/full-node/dsproof/controller.js +90 -0
  19. package/src/controllers/rest-api/full-node/dsproof/router.js +51 -0
  20. package/src/controllers/rest-api/full-node/mining/controller.js +99 -0
  21. package/src/controllers/rest-api/full-node/mining/router.js +52 -0
  22. package/src/controllers/rest-api/full-node/rawtransactions/controller.js +333 -0
  23. package/src/controllers/rest-api/full-node/rawtransactions/router.js +58 -0
  24. package/src/controllers/rest-api/index.js +33 -2
  25. package/src/controllers/rest-api/slp/controller.js +218 -0
  26. package/src/controllers/rest-api/slp/router.js +55 -0
  27. package/src/controllers/timer-controller.js +1 -1
  28. package/src/use-cases/fulcrum-use-cases.js +155 -0
  29. package/src/use-cases/full-node-control-use-cases.js +24 -0
  30. package/src/use-cases/full-node-dsproof-use-cases.js +24 -0
  31. package/src/use-cases/full-node-mining-use-cases.js +28 -0
  32. package/src/use-cases/full-node-rawtransactions-use-cases.js +121 -0
  33. package/src/use-cases/index.js +12 -0
  34. package/src/use-cases/slp-use-cases.js +321 -0
  35. package/test/unit/controllers/blockchain-controller-unit.js +2 -3
  36. package/test/unit/controllers/control-controller-unit.js +88 -0
  37. package/test/unit/controllers/dsproof-controller-unit.js +117 -0
  38. package/test/unit/controllers/fulcrum-controller-unit.js +481 -0
  39. package/test/unit/controllers/mining-controller-unit.js +139 -0
  40. package/test/unit/controllers/rawtransactions-controller-unit.js +388 -0
  41. package/test/unit/controllers/rest-api-index-unit.js +76 -6
  42. package/test/unit/controllers/slp-controller-unit.js +312 -0
  43. package/test/unit/use-cases/fulcrum-use-cases-unit.js +297 -0
  44. package/test/unit/use-cases/full-node-control-use-cases-unit.js +53 -0
  45. package/test/unit/use-cases/full-node-dsproof-use-cases-unit.js +54 -0
  46. package/test/unit/use-cases/full-node-mining-use-cases-unit.js +84 -0
  47. package/test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js +267 -0
  48. package/test/unit/use-cases/slp-use-cases-unit.js +296 -0
  49. package/src/entities/event.js +0 -71
  50. package/test/integration/api/event-integration.js +0 -250
  51. package/test/integration/api/req-integration.js +0 -173
  52. package/test/integration/api/subscription-integration.js +0 -198
  53. package/test/integration/use-cases/manage-subscription-integration.js +0 -163
  54. package/test/integration/use-cases/publish-event-integration.js +0 -104
  55. package/test/integration/use-cases/query-events-integration.js +0 -95
  56. package/test/unit/entities/event-unit.js +0 -139
@@ -0,0 +1,321 @@
1
+ /*
2
+ Use cases for interacting with the SLP Indexer API service.
3
+ */
4
+
5
+ import wlogger from '../adapters/wlogger.js'
6
+ import BCHJS from '@psf/bch-js'
7
+ import SlpWallet from 'minimal-slp-wallet'
8
+ import SlpTokenMedia from 'slp-token-media'
9
+ import axios from 'axios'
10
+ import config from '../config/index.js'
11
+
12
+ const bchjs = new BCHJS()
13
+
14
+ class SlpUseCases {
15
+ constructor (localConfig = {}) {
16
+ this.adapters = localConfig.adapters
17
+
18
+ if (!this.adapters) {
19
+ throw new Error('Adapters instance required when instantiating SLP use cases.')
20
+ }
21
+
22
+ this.slpIndexer = this.adapters.slpIndexer
23
+ if (!this.slpIndexer) {
24
+ throw new Error('SLP Indexer adapter required when instantiating SLP use cases.')
25
+ }
26
+
27
+ // Allow bchjs to be injected for testing
28
+ this.bchjs = localConfig.bchjs || bchjs
29
+
30
+ // Get config
31
+ this.config = localConfig.config || config
32
+
33
+ // Initialize wallet (lazy initialization)
34
+ this.wallet = null
35
+ this.slpTokenMedia = null
36
+ this.walletInitialized = false
37
+ this.initializationPromise = null
38
+ }
39
+
40
+ // Initialize wallet and SlpTokenMedia asynchronously
41
+ async _ensureInitialized () {
42
+ if (this.walletInitialized) {
43
+ return
44
+ }
45
+
46
+ if (this.initializationPromise) {
47
+ return this.initializationPromise
48
+ }
49
+
50
+ this.initializationPromise = this._initialize()
51
+ return this.initializationPromise
52
+ }
53
+
54
+ async _initialize () {
55
+ try {
56
+ // Initialize wallet
57
+ this.wallet = new SlpWallet(undefined, {
58
+ restURL: this.config.restURL,
59
+ interface: 'rest-api'
60
+ })
61
+
62
+ // Wait for wallet to initialize
63
+ await this.wallet.walletInfoPromise
64
+
65
+ // Initialize SlpTokenMedia
66
+ this.slpTokenMedia = new SlpTokenMedia({
67
+ wallet: this.wallet,
68
+ ipfsGatewayUrl: this.config.ipfsGateway
69
+ })
70
+
71
+ this.walletInitialized = true
72
+ wlogger.info('SLP wallet and token media initialized')
73
+ } catch (err) {
74
+ wlogger.error('Error initializing SLP wallet:', err)
75
+ throw err
76
+ }
77
+ }
78
+
79
+ async getStatus () {
80
+ try {
81
+ return await this.slpIndexer.get('slp/status/')
82
+ } catch (err) {
83
+ wlogger.error('Error in SlpUseCases.getStatus()', err)
84
+ throw err
85
+ }
86
+ }
87
+
88
+ async getAddress ({ address }) {
89
+ try {
90
+ return await this.slpIndexer.post('slp/address/', { address })
91
+ } catch (err) {
92
+ wlogger.error('Error in SlpUseCases.getAddress()', err)
93
+ throw err
94
+ }
95
+ }
96
+
97
+ async getTxid ({ txid }) {
98
+ try {
99
+ return await this.slpIndexer.post('slp/tx/', { txid })
100
+ } catch (err) {
101
+ wlogger.error('Error in SlpUseCases.getTxid()', err)
102
+ throw err
103
+ }
104
+ }
105
+
106
+ async getTokenStats ({ tokenId, withTxHistory = false }) {
107
+ try {
108
+ return await this.slpIndexer.post('slp/token/', { tokenId, withTxHistory })
109
+ } catch (err) {
110
+ wlogger.error('Error in SlpUseCases.getTokenStats()', err)
111
+ throw err
112
+ }
113
+ }
114
+
115
+ async getTokenData ({ tokenId, withTxHistory = false }) {
116
+ try {
117
+ const tokenData = {}
118
+
119
+ // Get token stats from the Genesis TX of the token
120
+ const response = await this.slpIndexer.post('slp/token/', { tokenId, withTxHistory })
121
+ const tokenStats = response.tokenData
122
+
123
+ tokenData.genesisData = tokenStats
124
+
125
+ // Try to get immutable data
126
+ try {
127
+ const immutableData = tokenStats.documentUri
128
+ tokenData.immutableData = immutableData || ''
129
+ } catch (error) {
130
+ tokenData.immutableData = ''
131
+ }
132
+
133
+ // Try to get mutable data
134
+ try {
135
+ const mutableData = await this.getMutableCid({ tokenStats })
136
+ tokenData.mutableData = mutableData || ''
137
+ } catch (error) {
138
+ wlogger.warn('Error getting mutable data:', error)
139
+ tokenData.mutableData = ''
140
+ }
141
+
142
+ return tokenData
143
+ } catch (err) {
144
+ wlogger.error('Error in SlpUseCases.getTokenData()', err)
145
+ throw err
146
+ }
147
+ }
148
+
149
+ async getMutableCid ({ tokenStats }) {
150
+ // Validate input - this should throw, not be caught
151
+ if (!tokenStats || !tokenStats.documentHash) {
152
+ throw new Error('No documentHash property found in tokenStats')
153
+ }
154
+
155
+ try {
156
+ await this._ensureInitialized()
157
+
158
+ // Get the OP_RETURN data and decode it
159
+ const mutableData = await this.decodeOpReturn({ txid: tokenStats.documentHash })
160
+ const jsonData = JSON.parse(mutableData)
161
+
162
+ // mda = mutable data address
163
+ const mda = jsonData.mda
164
+
165
+ // Get the mda transaction history
166
+ const transactions = await this.wallet.getTransactions(mda)
167
+ wlogger.info(`MDA has ${transactions.length} transactions in its history.`)
168
+
169
+ const mdaTxs = transactions
170
+
171
+ let data = false
172
+
173
+ // These are used to filter blockchain data to find the most recent
174
+ // update to the MDA
175
+ let largestBlock = 700000
176
+ let largestTimestamp = 1666107111271
177
+ let bestEntry
178
+
179
+ // Used to track the number of transactions before the best candidate is found
180
+ let txCnt = 0
181
+
182
+ // Map each transaction of the mda
183
+ // If it finds an OP_RETURN, decode it and exit the loop
184
+ for (let i = 0; i < mdaTxs.length; i++) {
185
+ const tx = mdaTxs[i]
186
+ const txid = tx.tx_hash
187
+ txCnt++
188
+
189
+ data = await this.decodeOpReturn({ txid })
190
+
191
+ // Try parse the OP_RETURN data to a JSON object
192
+ if (data) {
193
+ try {
194
+ // Convert the OP_RETURN data to a JSON object
195
+ const obj = JSON.parse(data)
196
+
197
+ // Keep searching if this TX does not have a cid value
198
+ if (!obj.cid) continue
199
+
200
+ // Ensure data was generated by the MDA
201
+ const txData = await this.wallet.getTxData([txid])
202
+ const vinAddress = txData[0].vin[0].address
203
+
204
+ // Skip entry if it was not made by the MDA private key
205
+ if (mda !== vinAddress) {
206
+ continue
207
+ }
208
+
209
+ // First best entry found
210
+ if (!bestEntry) {
211
+ bestEntry = data
212
+ largestBlock = tx.height
213
+
214
+ if (obj.ts) {
215
+ largestTimestamp = obj.ts
216
+ }
217
+ } else {
218
+ // One candidate already found. Looking for potentially better entry
219
+
220
+ if (tx.height < largestBlock) {
221
+ // Exit loop if next candidate has an older block height
222
+ break
223
+ }
224
+
225
+ if (obj.ts && obj.ts < largestTimestamp) {
226
+ // Continue looping through entries if the current entry in
227
+ // the same block has a smaller timestamp
228
+ continue
229
+ }
230
+
231
+ bestEntry = data
232
+ largestBlock = tx.height
233
+ if (obj.ts) {
234
+ largestTimestamp = obj.ts
235
+ }
236
+ }
237
+ } catch (error) {
238
+ continue
239
+ }
240
+ }
241
+ }
242
+
243
+ wlogger.info(`${txCnt} transactions reviewed to find mutable data.`)
244
+
245
+ if (!bestEntry) {
246
+ return false
247
+ }
248
+
249
+ // Get the CID
250
+ const obj = JSON.parse(bestEntry)
251
+ const cid = obj.cid
252
+
253
+ if (!cid) {
254
+ return false
255
+ }
256
+
257
+ // Assuming that CID starts with ipfs://. Cutting out that prefix
258
+ const mutableCid = cid.substring(7)
259
+
260
+ return mutableCid
261
+ } catch (err) {
262
+ wlogger.error('Error in SlpUseCases.getMutableCid()', err)
263
+ return false
264
+ }
265
+ }
266
+
267
+ async decodeOpReturn ({ txid }) {
268
+ try {
269
+ if (!txid || typeof txid !== 'string') {
270
+ throw new Error('txid must be a string.')
271
+ }
272
+
273
+ // Get transaction data
274
+ const txData = await this.bchjs.Electrumx.txData(txid)
275
+ let data = false
276
+
277
+ // Map the vout of the transaction in search of an OP_RETURN
278
+ for (let i = 0; i < txData.details.vout.length; i++) {
279
+ const vout = txData.details.vout[i]
280
+
281
+ const script = this.bchjs.Script.toASM(
282
+ Buffer.from(vout.scriptPubKey.hex, 'hex')
283
+ ).split(' ')
284
+
285
+ // Exit on the first OP_RETURN found
286
+ if (script[0] === 'OP_RETURN') {
287
+ data = Buffer.from(script[1], 'hex').toString('ascii')
288
+ break
289
+ }
290
+ }
291
+
292
+ return data
293
+ } catch (error) {
294
+ wlogger.error('Error in SlpUseCases.decodeOpReturn()', error)
295
+ throw error
296
+ }
297
+ }
298
+
299
+ async getCIDData ({ cid }) {
300
+ try {
301
+ if (!cid || typeof cid !== 'string') {
302
+ throw new Error('cid must be a string.')
303
+ }
304
+
305
+ // Assuming that CID starts with ipfs://. Cutting out that prefix
306
+ const cidWithoutPrefix = cid.substring(7)
307
+
308
+ const dataUrl = `https://${cidWithoutPrefix}.ipfs.dweb.link/data.json`
309
+ wlogger.info(`Fetching IPFS data from: ${dataUrl}`)
310
+
311
+ const response = await axios.get(dataUrl)
312
+
313
+ return response.data
314
+ } catch (error) {
315
+ wlogger.error('Error in SlpUseCases.getCIDData()', error)
316
+ throw error
317
+ }
318
+ }
319
+ }
320
+
321
+ export default SlpUseCases
@@ -161,8 +161,7 @@ describe('#blockchain-controller.js', () => {
161
161
  it('should validate array size and call use case', async () => {
162
162
  const hash = 'a'.repeat(64)
163
163
  const req = createMockRequest({
164
- body: { hashes: [hash], verbose: true },
165
- locals: { proLimit: false }
164
+ body: { hashes: [hash], verbose: true }
166
165
  })
167
166
  const res = createMockResponse()
168
167
  mockUseCases.blockchain.getBlockHeaders.resolves(['result'])
@@ -172,7 +171,7 @@ describe('#blockchain-controller.js', () => {
172
171
  assert.equal(res.statusValue, 200)
173
172
  assert.deepEqual(res.jsonData, ['result'])
174
173
  assert.isTrue(
175
- mockAdapters.fullNode.validateArraySize.calledOnceWithExactly(1, { isProUser: false })
174
+ mockAdapters.fullNode.validateArraySize.calledOnceWithExactly(1)
176
175
  )
177
176
  assert.isTrue(
178
177
  mockUseCases.blockchain.getBlockHeaders.calledOnceWithExactly({
@@ -0,0 +1,88 @@
1
+ /*
2
+ Unit tests for ControlRESTController.
3
+ */
4
+
5
+ import { assert } from 'chai'
6
+ import sinon from 'sinon'
7
+
8
+ import ControlRESTController from '../../../src/controllers/rest-api/full-node/control/controller.js'
9
+ import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js'
10
+
11
+ describe('#control-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
+ control: {
22
+ getNetworkInfo: sandbox.stub().resolves({ version: 1 })
23
+ }
24
+ }
25
+
26
+ uut = new ControlRESTController({
27
+ adapters: mockAdapters,
28
+ useCases: mockUseCases
29
+ })
30
+ })
31
+
32
+ afterEach(() => {
33
+ sandbox.restore()
34
+ })
35
+
36
+ describe('#constructor()', () => {
37
+ it('should require adapters', () => {
38
+ assert.throws(() => {
39
+ // eslint-disable-next-line no-new
40
+ new ControlRESTController({ useCases: mockUseCases })
41
+ }, /Adapters library required/)
42
+ })
43
+
44
+ it('should require control use cases', () => {
45
+ assert.throws(() => {
46
+ // eslint-disable-next-line no-new
47
+ new ControlRESTController({ adapters: mockAdapters, useCases: {} })
48
+ }, /Control use cases required/)
49
+ })
50
+ })
51
+
52
+ describe('#root()', () => {
53
+ it('should return control status', async () => {
54
+ const req = createMockRequest()
55
+ const res = createMockResponse()
56
+
57
+ await uut.root(req, res)
58
+
59
+ assert.equal(res.statusValue, 200)
60
+ assert.deepEqual(res.jsonData, { status: 'control' })
61
+ })
62
+ })
63
+
64
+ describe('#getNetworkInfo()', () => {
65
+ it('should return network info on success', async () => {
66
+ const req = createMockRequest()
67
+ const res = createMockResponse()
68
+
69
+ await uut.getNetworkInfo(req, res)
70
+
71
+ assert.equal(res.statusValue, 200)
72
+ assert.deepEqual(res.jsonData, { version: 1 })
73
+ })
74
+
75
+ it('should handle errors via handleError', async () => {
76
+ const error = new Error('failure')
77
+ error.status = 503
78
+ mockUseCases.control.getNetworkInfo.rejects(error)
79
+ const req = createMockRequest()
80
+ const res = createMockResponse()
81
+
82
+ await uut.getNetworkInfo(req, res)
83
+
84
+ assert.equal(res.statusValue, 503)
85
+ assert.deepEqual(res.jsonData, { error: 'failure' })
86
+ })
87
+ })
88
+ })
@@ -0,0 +1,117 @@
1
+ /*
2
+ Unit tests for DSProofRESTController.
3
+ */
4
+
5
+ import { assert } from 'chai'
6
+ import sinon from 'sinon'
7
+
8
+ import DSProofRESTController from '../../../src/controllers/rest-api/full-node/dsproof/controller.js'
9
+ import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js'
10
+
11
+ describe('#dsproof-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
+ dsproof: {
22
+ getDSProof: sandbox.stub().resolves({ proof: true })
23
+ }
24
+ }
25
+
26
+ uut = new DSProofRESTController({
27
+ adapters: mockAdapters,
28
+ useCases: mockUseCases
29
+ })
30
+ })
31
+
32
+ afterEach(() => {
33
+ sandbox.restore()
34
+ })
35
+
36
+ describe('#constructor()', () => {
37
+ it('should require adapters', () => {
38
+ assert.throws(() => {
39
+ // eslint-disable-next-line no-new
40
+ new DSProofRESTController({ useCases: mockUseCases })
41
+ }, /Adapters library required/)
42
+ })
43
+
44
+ it('should require dsproof use cases', () => {
45
+ assert.throws(() => {
46
+ // eslint-disable-next-line no-new
47
+ new DSProofRESTController({ adapters: mockAdapters, useCases: {} })
48
+ }, /DSProof use cases required/)
49
+ })
50
+ })
51
+
52
+ describe('#root()', () => {
53
+ it('should return dsproof status', async () => {
54
+ const req = createMockRequest()
55
+ const res = createMockResponse()
56
+
57
+ await uut.root(req, res)
58
+
59
+ assert.equal(res.statusValue, 200)
60
+ assert.deepEqual(res.jsonData, { status: 'dsproof' })
61
+ })
62
+ })
63
+
64
+ describe('#getDSProof()', () => {
65
+ it('should validate txid presence', async () => {
66
+ const req = createMockRequest()
67
+ const res = createMockResponse()
68
+
69
+ await uut.getDSProof(req, res)
70
+
71
+ assert.equal(res.statusValue, 400)
72
+ assert.include(res.jsonData.error, 'txid can not be empty')
73
+ })
74
+
75
+ it('should validate txid length', async () => {
76
+ const req = createMockRequest({ params: { txid: 'abc' } })
77
+ const res = createMockResponse()
78
+
79
+ await uut.getDSProof(req, res)
80
+
81
+ assert.equal(res.statusValue, 400)
82
+ assert.include(res.jsonData.error, 'txid must be of length 64')
83
+ })
84
+
85
+ it('should call use case with derived verbose when valid', async () => {
86
+ const txid = 'a'.repeat(64)
87
+ const req = createMockRequest({
88
+ params: { txid },
89
+ query: { verbose: 'true' }
90
+ })
91
+ const res = createMockResponse()
92
+
93
+ await uut.getDSProof(req, res)
94
+
95
+ assert.equal(res.statusValue, 200)
96
+ assert.isTrue(mockUseCases.dsproof.getDSProof.calledOnceWithExactly({
97
+ txid,
98
+ verbose: 3
99
+ }))
100
+ assert.deepEqual(res.jsonData, { proof: true })
101
+ })
102
+
103
+ it('should handle errors via handleError', async () => {
104
+ const txid = 'a'.repeat(64)
105
+ const error = new Error('failure')
106
+ error.status = 422
107
+ mockUseCases.dsproof.getDSProof.rejects(error)
108
+ const req = createMockRequest({ params: { txid } })
109
+ const res = createMockResponse()
110
+
111
+ await uut.getDSProof(req, res)
112
+
113
+ assert.equal(res.statusValue, 422)
114
+ assert.deepEqual(res.jsonData, { error: 'failure' })
115
+ })
116
+ })
117
+ })