diff --git a/.gitignore b/.gitignore index fb55d08..ab2cbc8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ build/Release # Dependency directories node_modules/ +# Dependency versions +package-lock.json # Optional npm cache directory .npm diff --git a/index.js b/index.js index 9dc363c..0c3040e 100644 --- a/index.js +++ b/index.js @@ -14,11 +14,15 @@ // const BitcoinPay = require('./src/wallet-pay-btc.js') const FeeEstimate = require('./src/fee-estimate.js') -const Provider = require('./src/electrum.js') +//const Provider = require('./src/electrum.js') +const ElectrumProvider = require('./src/electrum.js') +const BitcoinCoreProvider = require('./src/bitcoin-core.js') +//todo add Provider abstract class for both Electrum and Bitcoin Core. const KeyManager = require('./src/wallet-key-btc.js') module.exports = { BitcoinPay, FeeEstimate, - Provider, + ElectrumProvider, + BitcoinCoreProvider, KeyManager } diff --git a/package.json b/package.json index 8fe5be4..1d8cee2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "lib-wallet": "github:rbndg/lib-wallet-main#export-wallet", "lib-wallet-store": "github:tetherto/lib-wallet-store#v0.0.1", "tiny-secp256k1": "2.2.3", - "wallet-seed-bip39": "github:tetherto/lib-wallet-seed-bip39#v0.0.1" + "wallet-seed-bip39": "github:tetherto/lib-wallet-seed-bip39#v0.0.1", + "zeromq": "^6.1.2" }, "devDependencies": { "brittle": "^3.5.0", @@ -26,6 +27,9 @@ "test:send-tx": "brittle ./test/transactions.test.js", "test:pay": "brittle ./test/wallet-pay-btc.test.js", "test:key": "brittle ./test/wallet-key-btc.test.js", + "test:balance": "brittle ./test/balance.test.js", + "test:electrum": "brittle ./test/electrum.test.js", + "test:bitcoin-core": "brittle ./test/bitcoin-core.test.js", "test:currency": "brittle ./test/currency.test.js" }, "repository": { diff --git a/src/bitcoin-core.js b/src/bitcoin-core.js new file mode 100644 index 0000000..a74ee03 --- /dev/null +++ b/src/bitcoin-core.js @@ -0,0 +1,522 @@ +// Copyright 2024 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict' +const { EventEmitter } = require('events') +const Bitcoin = require('./currency') +const zmq = require("zeromq"); + + +// TODO: handle unsupported Bitcoin Core RPC methods + +function getBlockReward(height) { + const initialReward = Bitcoin.BN(50).times(100000000) // 50 BTC in satoshis + const halvingInterval = 210000 + const halvings = Math.floor(height / halvingInterval) + const reward = initialReward.dividedBy(Bitcoin.BN(2).pow(halvings)) + return new Bitcoin(reward, 'base') +} + +/** +* @class RequestCache +* @desc Cache requests to Bitcoin Core server +* @param {Object} config - configuration +* @param {Object} config.store - store to cache requests +* @param {Number} config.cache_timeout - cache timeout +* @param {Number} config.max_cache_size - max cache size +* @param {Number} config.cache_interval - cache interval +* @param {Number} config.cache_size - cache size +**/ +class RequestCache { + constructor(config) { + this.store = config.store + this._cache_expiry = config.cache_timeout || 300000 // 5min + this._max_cache_size = config.max_cache_size || 10000 + this._cache_size = 0 + this._closing = false + } + + async clear() { + return this.store.clear() + } + + async stop() { + clearInterval(this._timer) + return this.store.close() + } + + _startCacheTimer() { + this._timer = setInterval(() => { + this.store.entries(async (k, [value, exp]) => { + if (Date.now() >= exp) return await this.store.delete(k) + }) + }, this._cache_interval) + } + + async _getCacheIndex() { + return await (this.store.get('cache_index')) || [] + } + + async _removeOldest() { + const index = await this._getCacheIndex() + const key = index.shift() + await this.store.delete(key) + await this.store.put('cache_index', index) + } + + async set(key, value) { + let data + if (this._cache_size >= this._max_session_size) { + await this._removeOldest() + } + if (!value.expiry) { + data = [value, Date.now() + this._cache_expiry] + } else { + data = [value, value.expiry] + } + const index = await this._getCacheIndex() + index.push(key) + await this.store.put('cache_index', index) + this.size = index.length + return this.store.put(key, data) + } + + async get(key) { + const data = await this.store.get(key) + return data ? data[0] : null + } + + get size() { + return this._cache_size + } + + set size(val) { + return null + } +} + +class BitcoinCore extends EventEmitter { + constructor(config = {}) { + super() + this._host = config.host || '127.0.0.1' + this._port = config.port || 18443 + this._user = config.user || 'user' + this._pass = config.pass || 'password' + this._auth = Buffer.from(`${this._user}:${this._pass}`).toString('base64'); + this._wallet = config.wallet || 'main.dat' + + this._zmqPort = config.zmqPort || 28334 + this._socket = null + this._is_socket_open = false + this._is_blockhash_subscribed = false + this._is_raw_tx_subscribed = false + this._internal_event = new EventEmitter(); + this._address_subscriptions = [] + + this._net = config.net || require('net') + this.clientState = 0 + this.requests = new Map() + this.cache = new RequestCache({ store: config.store.newInstance({ name: 'bitcoin-core-cache' }) }) + this.block_height = 0 + this._max_cache_size = 10 + this._reconnect_count = 0 + this._max_attempt = 10 + this._reconnect_interval = 2000 + this._closed = false + + } + + static OutTypes = { + 0: 'non-standard', + 1: 'standard' + } + + /** + * Connect to Bitcoin Core + * @param {Object} opts - options + * @param {Boolean} opts.reconnect - reconnect if connection is lost. + **/ + connect(opts = {}) { + if (opts.reconnect) this._reconnect_count = 0 + return new Promise((resolve, reject) => { + this._client = this._net.connect(this._port, this._host, () => { + console.log('Connected to Bitcoin Core'); + this.clientState = 1 + this._reconnect_count = 0 + resolve() + }) + this._client.on('data', (data) => { + const response = data.toString().split('\n') + response.forEach((data) => { + if (!data) return + this._handleResponse(data) + }) + }) + this._client.once('close', () => { + this.clientState = 0 + this._reconn(resolve, reject, _err) + }) + let _err + this._client.once('error', (err) => { + _err = err + this.clientState = 0 + }) + }) + } + + async _reconn(resolve, reject, err = {}) { + const errMsg = err.message || err.errors?.map(e => e.message).join(' ') + if (this._reconnect_count >= this._max_attempt) + return reject(new Error('gave up connecting to Bitcoin Core ' + errMsg)) + setTimeout(async () => { + if (this._reconnect_count >= this._max_attempt) + return reject(new Error('gave up connecting to Bitcoin Core ' + errMsg)) + this._reconnect_count++ + try { + await this.connect() + } catch (err) { + if (this._reconnect_count >= this._max_attempt) return reject(err) + await this._reconn(resolve, reject) + return + } + resolve() + }, this._reconnect_interval) + } + + _rpcPayload(method, params, id) { + return JSON.stringify({ + jsonrpc: '1.0', + id: id, + method: method, + params: params + }) + } + + _httpRequest(payload) { + return ( + `POST / HTTP/1.1\r\n` + + `Host: ${this._host}\r\n` + + `Authorization: Basic ${this._auth}\r\n` + + `Content-Type: application/json\r\n` + + `Content-Length: ${Buffer.byteLength(payload)}\r\n` + + `\r\n` + + `${payload}` + ) + } + + _makeRequest(method, params) { + return new Promise((resolve, reject) => { + if (this._closed) return reject(new Error('client closed')) + if (this.clientState !== 1) { + return reject(new Error('client not connected')) + } + const id = Date.now() + '-' + parseInt(Math.random() * 100000000) + const payload = this._rpcPayload(method, params, id) + const request = this._httpRequest(payload) + this.requests.set(id, [resolve, reject, method]) + this._client.write(request) + }) + } + + _handleResponse(data) { + let resp + try { + resp = JSON.parse(data.toString()) + } catch (err) { + this.emit('request-error', err) + return + } + + if (resp?.method?.includes('.subscribe')) { + this.emit(resp.method, resp.params.pop()) + this.requests.delete(resp?.id) + return + } + + const _resp = this.requests.get(resp.id) + const [resolve, reject, method] = _resp || [] + + if (resp.error) { + if (reject) { + reject(new Error(`RPC Error: ${JSON.stringify(resp.error)} - ${method}`)) + } + return this.requests.delete(resp.id) + } + + if (!resolve) + return this.emit('request-error', `no handler for response id: ${resp.id} - ${JSON.stringify(resp)}`) + + const isNull = resp.result === null + + resolve(isNull ? null : (resp.result || resp.error)) + this.requests.delete(resp.id) + } + + //todo change address to scripthash + async getAddressHistory(opts, address) { + const receivedTx = await this._makeRequest('listreceivedbyaddress', [0, false, true, address]) + const history = receivedTx[0].txids + const txData = [] + for (const i in history) { + const td = await this.getTransaction(history[i], opts) + txData.push(td) + } + return txData + } + + _getTransaction(txid) { + return this._makeRequest('gettransaction', [txid, true, true]) + } + + //todo change address to scripthash + async getBalance(address) { + const confirmed = await this._makeRequest('getreceivedbyaddress', [address, 1]) + const unconfirmed = await this._makeRequest('getreceivedbyaddress', [address, 0]) + return { + confirmed: confirmed * 10 ** 8 || 0, + unconfirmed: (unconfirmed - confirmed) * 10 ** 8 || 0 + } + } + + async broadcastTransaction(tx) { + return this._makeRequest('sendrawtransaction', [tx]) + } + + _processTxVout(vout, tx) { + return { + address: this._getTxAddress(vout.scriptPubKey), + value: new Bitcoin(vout.value, 'main'), + witness_hex: vout?.scriptPubKey.hex, + index: vout.n, + txid: tx.txid, + height: tx.height + } + } + + _procTxHeight(tx) { + if (!tx.confirmations) { + tx.height = 0 + } else { + tx.height = this.block_height - (tx.confirmations - 1) + } + return tx + } + + async _txGet(txid, opts) { + const cache = this.cache + + if (opts.cache === false) { + let data = await this._getTransaction(txid) + data = this._procTxHeight(data) + await cache.set(txid, data) + return data + } + const cacheValue = await cache.get(txid) + if (cacheValue && cacheValue.height !== 0) { + return cacheValue + } + let data = await this._getTransaction(txid) + data = this._procTxHeight(data) + await cache.set(txid, data) + return data + } + + /** + * @description get transaction details. Store tx in cache. + */ + async getTransaction(txid, opts = {}) { + const data = { + txid, + out: [], + in: [], + unconfirmed_inputs: [], + std_out: [], + std_in: [] + } + + const tx = await this._txGet(txid, opts) + data.height = tx.height + + let totalOut = new Bitcoin(0, 'main') + data.out = tx.decoded.vout.map((vout) => { + const newvout = this._processTxVout(vout, tx) + if (!newvout || !newvout.address) { + data.std_out.push(false) + return null + } + data.std_out.push(true) + totalOut = totalOut.add(newvout.value) + newvout.tx_height = tx.height + return newvout + }).filter(Boolean) + + let totalIn = new Bitcoin(0, 'main') + data.in = await Promise.all(tx.decoded.vin.map(async (vin) => { + if (vin.coinbase) { + const value = getBlockReward(tx.height - 1) + data.std_in.push(false) + return { + prev_txid: `${vin.coinbase}00000000`, + prev_index: 0, + prev_tx_height: tx.height - 1, + txid: vin.coinbase, + address: vin.coinbase, + out_type: 0, + value + } + } + data.std_in.push(false) + const txDetail = await this._txGet(vin.txid, opts) + const newvin = this._processTxVout(txDetail.decoded.vout[vin.vout], tx) + newvin.prev_txid = vin.txid + newvin.prev_index = vin.vout + newvin.prev_tx_height = txDetail.height + if (txDetail.height === 0) data.unconfirmed_inputs.push(vin.txid) + totalIn = totalIn.add(newvin.value) + return newvin + })) + + if (totalIn.toNumber() === 0) { + data.fee = totalIn + } else { + data.fee = totalIn.minus(totalOut) + } + + return data + } + + _getTxAddress(scriptPubKey) { + if (scriptPubKey.address) return scriptPubKey.address + // if (scriptPubKey.addresses) return scriptPubKey.addresses + // Non standard outputs like OP_RETURN, multi-sig + return null + } + + async subscribeToBlocks() { + if (!this._is_socket_open) + this._startSocket() + + this._socket.subscribe("hashblock") + this._is_blockhash_subscribed = true; + this._internal_event.on("hashblock", this._handleBlockEvent) + } + + _handleBlockEvent = async (message) => { + const messageHex = message.toString("hex"); + const rawBlock = await this.rpc('getblock', [messageHex, 0]) + const block = await this.rpc('getblock', [messageHex, 1]) + this.emit('new-block', { height: block.height, hex: rawBlock }) + } + + async unsubscribeFromBlocks() { + this._is_blockhash_subscribed = false + this._internal_event.off("hashblock", this._handleBlockEvent) + return true + } + + async close() { + this._closed = true + await this._stopClient() + await this.cache.stop() + } + + _stopClient() { + return new Promise((resolve) => { + this.removeAllListeners() + this.clientState = 0 + this._reconnect_count = this._max_attempt + this._client.on('end', () => resolve()) + this._client.end() + }) + } + + rpc(method, params) { + return this._makeRequest(method, params) + } + + async ping(opts) { + const res = await this._makeRequest('ping', []) + if (!res) return 'pong' + throw new Error('ping failed') + } + + _startSocket() { + this._socket = new zmq.Subscriber() + this._socket.connect(`tcp://${this._host}:${this._zmqPort}`); + this._is_socket_open = true; + (async () => { + try { + //todo find a way to stop the loop without waiting for a new message + for await (const [topic, message] of this._socket) { + if (!this._isSocketUsed()) break + const topicStr = topic.toString(); + const messageHex = message.toString("hex"); + + this._internal_event.emit(topicStr, messageHex) + } + } + catch (err) { + console.error("Error in listener loop:", err); + } finally { + this._socket.close() + this._is_socket_open = false + } + })(); + } + + _isSocketUsed() { + return this._is_blockhash_subscribed || this._is_raw_tx_subscribed + } + + //todo change input to scripthash + async subscribeToAddress(address) { + if (!this._is_socket_open) + this._startSocket() + + this._address_subscriptions.push(address) + if (this._address_subscriptions.length > 1) { + return + } + + this._socket.subscribe("rawtx") + this._is_raw_tx_subscribed = true + this._internal_event.on("rawtx", this._handleTxEvent) + } + + _handleTxEvent = async (message) => { + const messageHex = message.toString("hex") + const tx = await this.rpc('decoderawtransaction', [messageHex]) + const addressUtxo = tx.vout.filter(async (out) => this._address_subscriptions.includes(out.scriptPubKey.address)) + if (addressUtxo.length) { + //todo calculate and emit transaction status + this.emit('new-tx', tx) + } + } + + //todo change input to scripthash + async unsubscribeFromAddress(address) { + this._address_subscriptions = this._address_subscriptions.filter(a => a !== address) + if (this._address_subscriptions.length === 0) { + this._is_raw_tx_subscribed = false + this._internal_event.off("rawtx", this._handleTxEvent) + } + return true + } + + isConnected() { + return this.clientState === 1 + } +} + +module.exports = BitcoinCore \ No newline at end of file diff --git a/src/electrum.js b/src/electrum.js index 8426b19..d117271 100644 --- a/src/electrum.js +++ b/src/electrum.js @@ -18,7 +18,7 @@ const Bitcoin = require('./currency') // TODO: handle unsupported electrum RPC methods // -function getBlockReward (height) { +function getBlockReward(height) { const initialReward = Bitcoin.BN(50).times(100000000) // 50 BTC in satoshis const halvingInterval = 210000 const halvings = Math.floor(height / halvingInterval) @@ -37,7 +37,7 @@ function getBlockReward (height) { * @param {Number} config.cache_size - cache size **/ class RequestCache { - constructor (config) { + constructor(config) { this.store = config.store this._cache_expiry = config.cache_timeout || 300000 // 5min this._max_cache_size = config.max_cache_size || 10000 @@ -45,16 +45,16 @@ class RequestCache { this._closing = false } - async clear () { + async clear() { return this.store.clear() } - async stop () { + async stop() { clearInterval(this._timer) return this.store.close() } - _startCacheTimer () { + _startCacheTimer() { this._timer = setInterval(() => { this.store.entries(async (k, [value, exp]) => { if (Date.now() >= exp) return await this.store.delete(k) @@ -62,18 +62,18 @@ class RequestCache { }, this._cache_interval) } - async _getCacheIndex () { + async _getCacheIndex() { return await (this.store.get('cache_index')) || [] } - async _removeOldest () { + async _removeOldest() { const index = await this._getCacheIndex() const key = index.shift() await this.store.delete(key) await this.store.put('cache_index', index) } - async set (key, value) { + async set(key, value) { let data if (this._cache_size >= this._max_session_size) { await this._removeOldest() @@ -90,22 +90,22 @@ class RequestCache { return this.store.put(key, data) } - async get (key) { + async get(key) { const data = await this.store.get(key) return data ? data[0] : null } - get size () { + get size() { return this._cache_size } - set size (val) { + set size(val) { return null } } class Electrum extends EventEmitter { - constructor (config = {}) { + constructor(config = {}) { super() this._subscribe() this.port = config.port || 8001 @@ -127,7 +127,7 @@ class Electrum extends EventEmitter { 1: 'standard' } - _subscribe () { + _subscribe() { this.on('blockchain.headers.subscribe', (height) => { this.block_height = height.height this.emit('new-block', height) @@ -143,7 +143,7 @@ class Electrum extends EventEmitter { * @param {Object} opts - options * @param {Boolean} opts.reconnect - reconnect if connection is lost. **/ - connect (opts = {}) { + connect(opts = {}) { if (opts.reconnect) this._reconnect_count = 0 return new Promise((resolve, reject) => { this._client = this._net.createConnection(this.port, this.host, () => { @@ -170,7 +170,7 @@ class Electrum extends EventEmitter { }) } - async _reconn (resolve, reject, err = {}) { + async _reconn(resolve, reject, err = {}) { const errMsg = err.message || err.errors?.map(e => e.message).join(' ') if (this._reconnect_count >= this._max_attempt) return reject(new Error('gave up connecting to electrum ' + errMsg)) setTimeout(async () => { @@ -187,7 +187,7 @@ class Electrum extends EventEmitter { }, this._reconnect_interval) } - _rpcPayload (method, params, id) { + _rpcPayload(method, params, id) { return JSON.stringify({ jsonrpc: '2.0', id, @@ -196,7 +196,7 @@ class Electrum extends EventEmitter { }) } - _makeRequest (method, params) { + _makeRequest(method, params) { return new Promise((resolve, reject) => { if (this._closed) return reject(new Error('client closed')) if (this.clientState !== 1) { @@ -209,7 +209,7 @@ class Electrum extends EventEmitter { }) } - _handleResponse (data) { + _handleResponse(data) { let resp try { resp = JSON.parse(data.toString()) @@ -242,7 +242,7 @@ class Electrum extends EventEmitter { this.requests.delete(resp.id) } - async getAddressHistory (opts, scriptHash) { + async getAddressHistory(opts, scriptHash) { const history = await this._makeRequest('blockchain.scripthash.get_history', [scriptHash]) const txData = [] for (const index in history) { @@ -253,19 +253,19 @@ class Electrum extends EventEmitter { return txData } - _getTransaction (txid) { + _getTransaction(txid) { return this._makeRequest('blockchain.transaction.get', [txid, true]) } - _getBalance (scriptHash) { + getBalance(scriptHash) { return this._makeRequest('blockchain.scripthash.get_balance', [scriptHash]) } - async broadcastTransaction (tx) { + async broadcastTransaction(tx) { return this._makeRequest('blockchain.transaction.broadcast', [tx]) } - _processTxVout (vout, tx) { + _processTxVout(vout, tx) { return { address: this._getTxAddress(vout.scriptPubKey), value: new Bitcoin(vout.value, 'main'), @@ -276,7 +276,7 @@ class Electrum extends EventEmitter { } } - _procTxHeight (tx) { + _procTxHeight(tx) { if (!tx.confirmations) { tx.height = 0 } else { @@ -285,7 +285,7 @@ class Electrum extends EventEmitter { return tx } - async _txGet (txid, opts) { + async _txGet(txid, opts) { const cache = this.cache if (opts.cache === false) { @@ -307,7 +307,7 @@ class Electrum extends EventEmitter { /** * @description get transaction details. Store tx in cache. */ - async getTransaction (txid, opts = {}) { + async getTransaction(txid, opts = {}) { const data = { txid, out: [], @@ -368,26 +368,30 @@ class Electrum extends EventEmitter { return data } - _getTxAddress (scriptPubKey) { + _getTxAddress(scriptPubKey) { if (scriptPubKey.address) return scriptPubKey.address // if (scriptPubKey.addresses) return scriptPubKey.addresses // Non standard outputs like OP_RETURN, multi-sig return null } - async subscribeToBlocks () { + async subscribeToBlocks() { const height = await this._makeRequest('blockchain.headers.subscribe', []) this.block_height = height.height this.emit('new-block', height) } - async close () { + async unsubscribeFromBlocks() { + return this._makeRequest('blockchain.headers.unsubscribe', []) + } + + async close() { this._closed = true await this._stopClient() await this.cache.stop() } - _stopClient () { + _stopClient() { return new Promise((resolve) => { this.removeAllListeners() this.clientState = 0 @@ -397,24 +401,25 @@ class Electrum extends EventEmitter { }) } - rpc (method, params) { + rpc(method, params) { return this._makeRequest(method, params) } - async ping (opts) { + async ping(opts) { const res = await this._makeRequest('server.ping', []) if (!res) return 'pong' throw new Error('ping failed') } - async subscribeToAddress (scriptHash) { + async subscribeToAddress(scriptHash) { return this._makeRequest('blockchain.scripthash.subscribe', [scriptHash]) } - async unsubscribeFromAddress (scriptHash) { + async unsubscribeFromAddress(scriptHash) { + return this._makeRequest('blockchain.scripthash.unsubscribe', [scriptHash]) } - isConnected () { + isConnected() { return this.clientState === 1 } } diff --git a/test/balance-test.js b/test/balance.test.js similarity index 100% rename from test/balance-test.js rename to test/balance.test.js diff --git a/test/bitcoin-core.test.js b/test/bitcoin-core.test.js new file mode 100644 index 0000000..aeea85c --- /dev/null +++ b/test/bitcoin-core.test.js @@ -0,0 +1,200 @@ +// Copyright 2024 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +const { test, solo, skip, hook } = require('brittle') +const { WalletStoreMemory } = require('lib-wallet-store') +const { bitcoinCoreConnect, regtestNode } = require('./test-helpers.js') + +let bitcoinCoreProvider; +let bc; + +hook('Setup', async t => { + bc = await regtestNode() + await bc.init() + const balance = (await bc.getBalance()).result + if (balance <= 1) { + await bc.mine({ blocks: 101 }) + } + + bitcoinCoreProvider = await bitcoinCoreConnect({ + store: new WalletStoreMemory({}) + }) +}) + + +test('Bitcoin Core connected successfully', async t => { + t.plan(1) + t.ok(bitcoinCoreProvider.isConnected(), 'Client should be connected') +}) + + +test('Bitcoin Core subscribes and unsubscribes to blocks', async t => { + await bitcoinCoreProvider.subscribeToBlocks() + + await new Promise((resolve, reject) => { + bitcoinCoreProvider.once('new-block', async (height) => { + try { + t.plan(3) + t.pass('Should receive new block notification') + const info = await bc.getBlockchainInfo() + t.is(height.height, info.result.blocks, 'Should receive the correct block height') + const result = await bitcoinCoreProvider.unsubscribeFromBlocks() + t.ok(result, 'Should unsubscribe from blocks notifications') + resolve(); + } catch (err) { + reject(err); + } + }) + + bc.mine({ blocks: 1 }) + }) +}) + + +test('Bitcoin Core subscribes and unsubscribes to address', async t => { + const to = await bc.getNewAddress() + + await bitcoinCoreProvider.subscribeToAddress(to.result) + await bc.sendToAddress({ address: to.result, amount: 0.1 }) + + await new Promise((resolve, reject) => { + bitcoinCoreProvider.once('new-tx', async (data) => { + try { + t.plan(2) + t.pass('Should receive new transaction notification') + const result = await bitcoinCoreProvider.unsubscribeFromAddress(to.result) + t.ok(result, 'Should unsubscribe from address notifications') + resolve(); + } catch (err) { + reject(err); + } + }) + }) +}) + + +test('Bitcoin Core getTransaction returns transaction details', async t => { + const amount = 0.1 + const to = await bc.getNewAddress() + const tx = await bc.sendToAddress({ address: to.result, amount: amount }) + + const bitcoinCoreTx = await bitcoinCoreProvider.getTransaction(tx.result) + + t.plan(4) + t.is(bitcoinCoreTx.txid, tx.result, 'Should get single transaction') + const utxo = bitcoinCoreTx.out.find(out => out.address === to.result) + t.is(utxo.address, to.result, 'Should get to address') + t.is(utxo.value.amount, amount.toString(), 'Should get amount') + t.exception(bitcoinCoreProvider.getTransaction('invalid-txid'), 'Should throw error for invalid txid') +}) + + +test('Bitcoin Core getBalance returns confirmed and unconfirmed balances', async t => { + const amount = 0.1 + const to = await bc.getNewAddress() + await bitcoinCoreProvider.subscribeToAddress(to.result) + await bc.sendToAddress({ address: to.result, amount: amount }) + + await new Promise((resolve, reject) => { + bitcoinCoreProvider.once('new-tx', async (data) => { + try { + const balance = await bitcoinCoreProvider.getBalance(to.result) + + t.plan(3) + t.is(balance.confirmed, 0, 'Confirmed balance should be 0') + t.is(balance.unconfirmed, amount * 10 ** 8, `Unconfirmed balance should be ${amount}`) + t.exception(bitcoinCoreProvider.getBalance('invalid-address'), 'Should throw error for invalid address') + + resolve(); + } catch (err) { + reject(err); + } + finally { + await bitcoinCoreProvider.unsubscribeFromAddress(to.result) + } + }) + }) +}) + + +test('Bitcoin Core getAddressHistory returns transactions', async t => { + const amount = 0.1 + const to = await bc.getNewAddress() + await bitcoinCoreProvider.subscribeToAddress(to.result) + const tx1 = await bc.sendToAddress({ address: to.result, amount: amount }) + const tx2 = await bc.sendToAddress({ address: to.result, amount: amount }) + + await new Promise((resolve, reject) => { + bitcoinCoreProvider.once('new-tx', async (data) => { + try { + const bitcoinCoreTxs = await bitcoinCoreProvider.getAddressHistory({}, to.result) + const bitcoinCoreTxIds = bitcoinCoreTxs.map(tx => tx.txid) + + t.plan(7) + t.ok(bitcoinCoreTxIds.includes(tx1.result), 'First transaction should be included') + const utxo1 = bitcoinCoreTxs.find(tx => tx.txid === tx1.result) + .out.find(out => out.address === to.result) + t.is(utxo1.address, to.result, 'Should get to address of first transaction') + t.is(utxo1.value.amount, amount.toString(), 'Should get amount of first transaction') + t.ok(bitcoinCoreTxIds.includes(tx2.result), 'Second transaction should be included') + const utxo2 = bitcoinCoreTxs.find(tx => tx.txid === tx2.result) + .out.find(out => out.address === to.result) + t.is(utxo2.address, to.result, 'Should get to address of second transaction') + t.is(utxo2.value.amount, amount.toString(), 'Should get amount of second transaction') + t.exception(bitcoinCoreProvider.getAddressHistory({}, 'invalid-scripthash'), 'Should throw error for invalid scripthash') + + resolve(); + } catch (err) { + reject(err); + } finally { + await bitcoinCoreProvider.unsubscribeFromAddress(to.result) + } + }) + }) +}) + + +test('Bitcoin Core broadcastTransaction successfully', async t => { + const utxoList = await bc.listUnspent({}) + const { txid, vout, amount } = utxoList.result[0] + const amountToSend = (amount - 0.01).toFixed(8) + const to = await bc.getNewAddress() + const rawTx = await bc.createRawTransaction( + { + inputs: [{ txid: txid, vout: vout }], + outputs: { [to.result]: amountToSend } + }) + const signedTx = await bc.signRawTransactionWithWallet({ hexstring: rawTx.result }) + + const tx = await bitcoinCoreProvider.broadcastTransaction(signedTx.result.hex) + const txDetails = await bitcoinCoreProvider.getTransaction(tx) + + t.plan(5) + t.ok(tx, 'Signed transaction should be broadcasted') + t.is(txDetails.txid, tx, 'Should get broadcasted transaction details') + const utxo = txDetails.out.find(out => out.address === to.result) + t.is(utxo.address, to.result, 'Should get to address of broadcasted transaction') + t.is(parseFloat(utxo.value.amount).toFixed(8), amountToSend, 'Should get amount of broadcasted transaction') + t.exception(bitcoinCoreProvider.broadcastTransaction('invalid-tx'), 'Should throw error for invalid tx') +}) + + +hook('Teardown', async t => { + if (bitcoinCoreProvider.isConnected()) { + await bitcoinCoreProvider.close() + // hack: to stop the zmq listener + await bc.mine({ blocks: 1 }) + } +}) \ No newline at end of file diff --git a/test/electrum.test.js b/test/electrum.test.js index 53c226d..c2cc6ab 100644 --- a/test/electrum.test.js +++ b/test/electrum.test.js @@ -12,34 +12,189 @@ // See the License for the specific language governing permissions and // limitations under the License. // -const test = require('brittle') +const { test, solo, skip, hook } = require('brittle') const { WalletStoreMemory } = require('lib-wallet-store') -const { newElectrum } = require('./test-helpers.js') +const { electrumConnect, regtestNode } = require('./test-helpers.js') -test('electrum', async function (t) { - const methods = [ - { - method: 'blockchain.transaction.get', - params: ['735d835e3ed852bf0c7fd7260da97cbd64fc04b07c259a8285f6817ca0670187', true], - expected: [ - '735d835e3ed852bf0c7fd7260da97cbd64fc04b07c259a8285f6817ca0670187', - 'txid' - ] - } - ] - - t.test('electrum methods', async function (t) { - const e = await newElectrum({ - store: new WalletStoreMemory({}) +let electrumProvider; +let bc; + +hook('Setup', async t => { + bc = await regtestNode() + await bc.init() + const balance = (await bc.getBalance()).result + if (balance <= 1) { + await bc.mine({ blocks: 101 }) + } + + electrumProvider = await electrumConnect({ + store: new WalletStoreMemory({}) + }) +}) + + +test('Electrum connected successfully', async t => { + t.plan(1) + t.ok(electrumProvider.isConnected(), 'Client should be connected') +}) + + +test('Electrum subscribes and unsubscribes to blocks', async t => { + await electrumProvider.subscribeToBlocks() + + await new Promise((resolve, reject) => { + electrumProvider.once('new-block', async (height) => { + try { + t.plan(3) + t.pass('Should receive new block notification') + const info = await bc.getBlockchainInfo() + t.is(height.height, info.result.blocks, 'Should receive the correct block height') + const result = await electrumProvider.unsubscribeFromBlocks() + t.ok(result, 'Should unsubscribe from blocks notifications') + resolve(); + } catch (err) { + reject(err); + } }) - const res = await e.ping() - t.ok(res === 'pong', 'ping') - - await Promise.all(methods.map(async function (m) { - const res = await e.rpc(m.method, m.params) - console.log(res) - t.ok(res[m.expected[1]] === m.expected[0], m.method) - })) - await e.close() + + bc.mine({ blocks: 1 }) }) }) + + +test('Electrum subscribes and unsubscribes to address', async t => { + const to = await bc.getNewAddress() + const scriptHash = bc.addressToScriptHash(to.result) + + await electrumProvider.subscribeToAddress(scriptHash) + bc.sendToAddress({ address: to.result, amount: 0.1 }) + + await new Promise((resolve, reject) => { + electrumProvider.once('new-tx', async (data) => { + try { + t.plan(2) + t.pass('Should receive new transaction notification') + const result = await electrumProvider.unsubscribeFromAddress(scriptHash) + t.ok(result, 'Should unsubscribe from scripthash notifications') + resolve(); + } catch (err) { + reject(err); + } + }) + }) +}) + + +test('Electrum getTransaction returns transaction details', async t => { + const amount = 0.1 + const to = await bc.getNewAddress() + const tx = await bc.sendToAddress({ address: to.result, amount: amount }) + + const electrumTx = await electrumProvider.getTransaction(tx.result) + + t.plan(4) + t.is(electrumTx.txid, tx.result, 'Should get single transaction') + const utxo = electrumTx.out.find(out => out.address === to.result) + t.is(utxo.address, to.result, 'Should get to address') + t.is(utxo.value.amount, amount.toString(), 'Should get amount') + t.exception(electrumProvider.getTransaction('invalid-txid'), 'Should throw error for invalid txid') +}) + + +test('Electrum getBalance returns confirmed and unconfirmed balances', async t => { + const amount = 0.1 + const to = await bc.getNewAddress() + const scriptHash = bc.addressToScriptHash(to.result) + await electrumProvider.subscribeToAddress(scriptHash) + await bc.sendToAddress({ address: to.result, amount: amount }) + + await new Promise((resolve, reject) => { + electrumProvider.once('new-tx', async (data) => { + try { + const balance = await electrumProvider.getBalance(scriptHash) + + t.plan(3) + t.is(balance.confirmed, 0, 'Confirmed balance should be 0') + t.is(balance.unconfirmed, amount * 10 ** 8, `Unconfirmed balance should be ${amount}`) + t.exception(electrumProvider.getBalance('invalid-scripthash'), 'Should throw error for invalid scripthash') + + resolve(); + } catch (err) { + reject(err); + } + finally { + await electrumProvider.unsubscribeFromAddress(scriptHash) + } + }) + }) +}) + + +test('Electrum getAddressHistory returns transactions', async t => { + const amount = 0.1 + const to = await bc.getNewAddress() + const scriptHash = bc.addressToScriptHash(to.result) + await electrumProvider.subscribeToAddress(scriptHash) + const tx1 = await bc.sendToAddress({ address: to.result, amount: amount }) + const tx2 = await bc.sendToAddress({ address: to.result, amount: amount }) + + await new Promise((resolve, reject) => { + electrumProvider.once('new-tx', async (data) => { + try { + const electrumTxs = await electrumProvider.getAddressHistory({}, scriptHash) + const electrumTxIds = electrumTxs.map(tx => tx.txid) + + t.plan(7) + t.ok(electrumTxIds.includes(tx1.result), 'First transaction should be included') + const utxo1 = electrumTxs.find(tx => tx.txid === tx1.result) + .out.find(out => out.address === to.result) + t.is(utxo1.address, to.result, 'Should get to address of first transaction') + t.is(utxo1.value.amount, amount.toString(), 'Should get amount of first transaction') + t.ok(electrumTxIds.includes(tx2.result), 'Second transaction should be included') + const utxo2 = electrumTxs.find(tx => tx.txid === tx2.result) + .out.find(out => out.address === to.result) + t.is(utxo2.address, to.result, 'Should get to address of second transaction') + t.is(utxo2.value.amount, amount.toString(), 'Should get amount of second transaction') + t.exception(electrumProvider.getAddressHistory({}, 'invalid-scripthash'), 'Should throw error for invalid scripthash') + + resolve(); + } catch (err) { + reject(err); + } finally { + await electrumProvider.unsubscribeFromAddress(scriptHash) + } + }) + }) +}) + + +test('Electrum broadcastTransaction successfully', async t => { + const utxoList = await bc.listUnspent({}) + const { txid, vout, amount } = utxoList.result[0] + const amountToSend = (amount - 0.01).toFixed(8) + const to = await bc.getNewAddress() + const rawTx = await bc.createRawTransaction( + { + inputs: [{ txid: txid, vout: vout }], + outputs: { [to.result]: amountToSend } + }) + const signedTx = await bc.signRawTransactionWithWallet({ hexstring: rawTx.result }) + + const tx = await electrumProvider.broadcastTransaction(signedTx.result.hex) + const txDetails = await electrumProvider.getTransaction(tx) + + t.plan(5) + t.ok(tx, 'Signed transaction should be broadcasted') + t.is(txDetails.txid, tx, 'Should get broadcasted transaction details') + const utxo = txDetails.out.find(out => out.address === to.result) + t.is(utxo.address, to.result, 'Should get to address of broadcasted transaction') + t.is(parseFloat(utxo.value.amount).toFixed(8), amountToSend, 'Should get amount of broadcasted transaction') + t.exception(electrumProvider.broadcastTransaction('invalid-tx'), 'Should throw error for invalid tx') +}) + + +hook('Teardown', async t => { + if (electrumProvider.isConnected()) { + await electrumProvider.close() + } +}) \ No newline at end of file diff --git a/test/sync-manager.test.js b/test/sync-manager.test.js new file mode 100644 index 0000000..b4232ae --- /dev/null +++ b/test/sync-manager.test.js @@ -0,0 +1,5 @@ +const { test } = require('brittle'); + +test('example test', async (t) => { + t.pass('TODO. This is a placeholder test'); +}); \ No newline at end of file diff --git a/test/test-conf.json.example b/test/test-conf.json.example index af3ebf0..9cd7511 100644 --- a/test/test-conf.json.example +++ b/test/test-conf.json.example @@ -1,4 +1,14 @@ { - "electrum_port" : 8001, - "electrum_host" : "127.0.0.1" -} + "electrum": { + "host": "127.0.0.1", + "port": 50001 + }, + "bitcoinCore": { + "host": "127.0.0.1", + "port": 18443, + "user": "user", + "pass": "password", + "wallet": "main.dat", + "zmqPort": 28334 + } +} \ No newline at end of file diff --git a/test/test-helpers.js b/test/test-helpers.js index 4befcc4..a81c3b1 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -3,34 +3,53 @@ const { WalletStoreHyperbee } = require('lib-wallet-store') const KeyManager = require('../src/wallet-key-btc.js') const BIP39Seed = require('wallet-seed-bip39') const Electrum = require('../src/electrum.js') -const { bitcoin } = require('../../wallet-test-tools/') +const BitcoinCore = require('../src/bitcoin-core.js') +const { bitcoin } = require('../../wallet-lib-test-tools/') const BitcoinCurr = require('../src/currency') const fs = require('fs') const testconf = require('./test-conf.json') -async function newElectrum (config = {}) { - config.host = testconf.electrum_host || 'localhost' || config.host - config.port = testconf.electrum_port || '8001' || config.port +async function electrumConnect(config = {}) { + config.host = testconf.electrum.host || 'localhost' + config.port = testconf.electrum.port || '8001' config.store = config.store || newStore() let e try { e = new Electrum(config) await e.connect() } catch (err) { - console.log('Error connecting to electrum', err) + console.log('Error connecting to Electrum:', err) } return e } +async function bitcoinCoreConnect(config = {}) { + config.host = testconf.bitcoinCore.host || 'localhost' + config.port = testconf.bitcoinCore.port || '18443' + config.user = testconf.bitcoinCore.user || 'bitcoin' + config.pass = testconf.bitcoinCore.pass || 'local321' + config.wallet = testconf.bitcoinCore.wallet || 'main.dat' + config.zmqPort = testconf.bitcoinCore.zmqPort || '28334' + config.store = config.store || newStore() + let bc + try { + bc = new BitcoinCore(config) + await bc.connect() + } catch (err) { + console.log('Error connecting to Bitcoin Core:', err) + } + return bc +} + const _datadir = './test-store' -function newStore (tmpStore) { +function newStore(tmpStore) { return tmpStore ? new WalletStoreHyperbee({ store_path: _datadir }) : new WalletStoreHyperbee() } let _regtest -async function regtestNode (opts = {}) { +async function regtestNode(opts = {}) { if (_regtest) return _regtest _regtest = new bitcoin.BitcoinCore({}) await _regtest.init() @@ -47,7 +66,7 @@ async function regtestNode (opts = {}) { * @param {boolean} config.tmpStore generate a temporary file store * @return {Promise} */ -async function activeWallet (config = {}) { +async function activeWallet(config = {}) { const _store = newStore() let seed const phrase = 'sell clock better horn digital prevent image toward sort first voyage detail inner regular improve' @@ -68,7 +87,7 @@ async function activeWallet (config = {}) { await km.init() const btcPay = new BitcoinPay({ asset_name: 'btc', - provider: await newElectrum({ store }), + provider: await electrumConnect({ store }), key_manager: km, store, network: 'regtest' @@ -78,7 +97,7 @@ async function activeWallet (config = {}) { return btcPay } -async function pause (ms) { +async function pause(ms) { console.log('Pausing.... ' + ms + 'ms') return new Promise((resolve) => { setTimeout(() => { @@ -87,7 +106,7 @@ async function pause (ms) { }) } -function promiseSteps (arr) { +function promiseSteps(arr) { const pass = {} for (const state of arr) { pass[state] = {} @@ -99,7 +118,7 @@ function promiseSteps (arr) { return pass } -async function rmDataDir () { +async function rmDataDir() { fs.rmSync(_datadir, { recursive: true, force: true }) } @@ -110,7 +129,8 @@ module.exports = { KeyManager, BIP39Seed, Electrum, - newElectrum, + electrumConnect, + bitcoinCoreConnect, activeWallet, regtestNode, pause, diff --git a/test/wallet-pay-btc.test.js b/test/wallet-pay-btc.test.js index 3c4d008..65cf63a 100644 --- a/test/wallet-pay-btc.test.js +++ b/test/wallet-pay-btc.test.js @@ -188,7 +188,7 @@ test('getTransactions', async (t) => { t.end() } }) - if(!c) t.fail('no tx found') + if (!c) t.fail('no tx found') }); (async () => { @@ -199,7 +199,7 @@ test('getTransactions', async (t) => { const btcPay = await activeWallet({ newWallet: true }) // const max = btcPay._syncManager._max_script_watch - async function newTx (tx) { + async function newTx(tx) { t.comment('checking balance transition between confirmed/pending/mempool', state, send) for (const key in send) { const addr = key @@ -251,7 +251,7 @@ test('getTransactions', async (t) => { await regtest.mine(1) await pass.confirmed.promise - const totalBalance = await btcPay.getBalance({}) + const totalBalance = await btcPay.getBalance({}) t.ok(+totalBalance.consolidated.toMainUnit() === amount, 'total wallet balance matches') await btcPay.destroy() t.end() @@ -259,7 +259,7 @@ test('getTransactions', async (t) => { })() test('pauseSync - internal and external', async () => { - async function runTest (sType, opts) { + async function runTest(sType, opts) { const btcPay = await activeWallet() const max = opts.max test('pauseSync: ' + sType, async (t) => { @@ -298,7 +298,7 @@ test('pauseSync - internal and external', async () => { }) test('syncing paths in order', async () => { - async function runTest (sType, opts) { + async function runTest(sType, opts) { const btcPay = await activeWallet() const max = 5 test('sync in order: ' + sType, async (t) => { @@ -407,12 +407,12 @@ solo('syncTransaction - balance check', async (t) => { t.comment('waiting for electrum to update') await btcPay._onNewTx() let checked = false - async function checkBal (pt, path, hasTx, gapCount) { - if(checked) return + async function checkBal(pt, path, hasTx, gapCount) { + if (checked) return checked = true t.ok(path === payAddr.path, 'first path is checked') const { hash, addr } = btcPay.keyManager.pathToScriptHash(path, 'p2wpkh') - const eBal = await btcPay.provider._getBalance(hash) + const eBal = await btcPay.provider.getBalance(hash) let bal try { bal = await btcPay.getBalance({}, addr.address)