diff --git a/src/routes/v3/electrumx.js b/src/routes/v3/electrumx.js index 8e30e7b..0c6ca7b 100644 --- a/src/routes/v3/electrumx.js +++ b/src/routes/v3/electrumx.js @@ -52,6 +52,7 @@ class Electrum { _this.router.post('/utxos', _this.utxosBulk) _this.router.get('/tx/data/:txid', _this.getTransactionDetails) _this.router.post('/tx/data', _this.transactionDetailsBulk) + _this.router.post('/tx/broadcast', _this.broadcastTransaction) _this.router.get('/block/headers/:height', _this.getBlockHeaders) _this.router.post('/block/headers', _this.blockHeadersBulk) _this.router.get('/balance/:address', _this.getBalance) @@ -491,6 +492,92 @@ class Electrum { } } + // Returns a promise that resolves to transaction ID of the broadcasted transaction or an error. + // Expects input to be a txHex string, and input validation to have already + // been done by parent, calling function. + async _broadcastTransactionWithElectrum (txHex) { + try { + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Broadcast the transaction hex to the ElectrumX server. + const electrumResponse = await _this.electrumx.request( + 'blockchain.transaction.broadcast', + txHex + ) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + return electrumResponse + } catch (err) { + // console.log('err: ', err) + + // Write out error to error log. + wlogger.error( + 'Error in elecrumx.js/_transactionDetailsFromElectrum(): ', + err + ) + throw err + } + } + + /** + * @api {post} /electrumx/tx/broadcast Broadcast a raw transaction + * @apiName Broadcast a raw transaction + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Broadcast a raw transaction and return the transaction ID on success or error on failure. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v3/electrumx/tx/broadcast" -H "accept: application/json" -H "Content-Type: text/plain" -d '020000000265d13ef402840c8a51f39779afb7ae4d49e4b0a3c24a3d0e7742038f2c679667010000006441dd1dd72770cadede1a7fd0363574846c48468a398ddfa41a9677c74cac8d2652b682743725a3b08c6c2021a629011e11a264d9036e9d5311e35b5f4937ca7b4e4121020797d8fd4d2fa6fd7cdeabe2526bfea2b90525d6e8ad506ec4ee3c53885aa309ffffffff65d13ef402840c8a51f39779afb7ae4d49e4b0a3c24a3d0e7742038f2c679667000000006441347d7f218c11c04487c1ad8baac28928fb10e5054cd4494b94d078cfa04ccf68e064fb188127ff656c0b98e9ce87f036d183925d0d0860605877d61e90375f774121028a53f95eb631b460854fc836b2e5d31cad16364b4dc3d970babfbdcc3f2e4954ffffffff035ac355000000000017a914189ce02e332548f4804bac65cba68202c9dbf822878dfd0800000000001976a914285bb350881b21ac89724c6fb6dc914d096cd53b88acf9ef3100000000001976a91445f1f1c4a9b9419a5088a3e9c24a293d7a150e6488ac00000000' + * + */ + // POST handler for broadcasting a single transaction + async broadcastTransaction (req, res, next) { + try { + const txHex = req.body + + if (typeof txHex !== 'string') { + res.status(400) + return res.json({ + success: false, + error: 'request body must be a string.' + }) + } + + wlogger.debug( + 'Executing electrumx/broadcastTransaction with this tx hex: ', + txHex + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._broadcastTransactionWithElectrum(txHex) + // console.log(`_utxosFromElectrumx(): ${JSON.stringify(electrumResponse, null, 2)}`) + + // Pass the error message if ElectrumX reports an error. + if (electrumResponse instanceof Error) { + res.status(400) + return res.json({ + success: false, + error: electrumResponse.message + }) + } + + res.status(200) + return res.json({ + success: true, + txid: electrumResponse + }) + } catch (err) { + wlogger.error('Error in electrumx.js/broadcastTransaction().', err) + + return _this.errorHandler(err, res) + } + } + // Returns a promise that resolves to block header data for a block height. // Expects input to be a height number, and input validation to have already // been done by parent, calling function. diff --git a/test/v3/a01-electrumx.js b/test/v3/a01-electrumx.js index 7492568..db5936a 100644 --- a/test/v3/a01-electrumx.js +++ b/test/v3/a01-electrumx.js @@ -906,6 +906,92 @@ describe('#ElectrumX Router', () => { }) }) + describe('#_broadcastTransactionWithElectrum', () => { + it('should return error object for invalid formatted transaction', async () => { + const invalidHex = mockData.txDetails.hex.substring(10) + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Error: the transaction was rejected by network rules.\n\nTX decode failed\n') + ) + + const result = await electrumxRoute._broadcastTransactionWithElectrum(invalidHex) + + assert.instanceOf(result, Error) + assert.include(result.message, 'TX decode failed') + }) + + it('should return txid for valid transaction', async function () { + // We cannot send an actual broadcast transaction to mainnet + if (process.env.TEST !== 'unit') return this.skip() + + const validHex = mockData.txDetails.hex + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + mockData.txDetails.hash + ) + + const result = await electrumxRoute._broadcastTransactionWithElectrum(validHex) + + assert.typeOf(result, 'string') + assert.equal(result, mockData.txDetails.hash) + }) + }) + + describe('#broadcastTransaction', () => { + it('should throw an error for a non-string', async () => { + req.body = 456 + + stubMethodForUnitTests( + electrumxRoute, + '_broadcastTransactionWithElectrum', + new Error('request body must be a string.') + ) + + const result = await electrumxRoute.broadcastTransaction(req, res) + + expectRouteError(res, result, 'request body must be a string.') + }) + + it('should throw an error object for invalid formatted transaction', async () => { + req.body = mockData.txDetails.hex.substring(10) + + stubMethodForUnitTests( + electrumxRoute, + '_broadcastTransactionWithElectrum', + new Error('Error: the transaction was rejected by network rules.\n\nTX decode failed\n') + ) + + const result = await electrumxRoute.broadcastTransaction(req, res) + + expectRouteError(res, result, 'TX decode failed') + }) + + it('should return txid for valid transaction', async function () { + // We cannot send an actual broadcast transaction to mainnet + if (process.env.TEST !== 'unit') return this.skip() + + req.body = mockData.txDetails.hex + + stubMethodForUnitTests( + electrumxRoute, + '_broadcastTransactionWithElectrum', + mockData.txDetails.hash + ) + + const result = await electrumxRoute.broadcastTransaction(req, res) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'txid') + assert.equal(result.txid, mockData.txDetails.hash) + }) + }) + describe('#_balanceFromElectrumx', () => { it('should throw error for invalid address', async () => { try {