From 1aa724491f0b4655f7f6a2833a3c9c29e771adfb Mon Sep 17 00:00:00 2001 From: Alessandro Rezzi Date: Fri, 1 Nov 2024 08:16:29 +0100 Subject: [PATCH] Network Refactor (#431) * Add missing virtual functions in Network class * Refactor network creation * Add missing JSdoc * Make fetchBlockbook member of ExplorerNetwork * Add a NetworkManager and refactor * sleep in network_manager * Update broken tests * Move tx error logic to network_manager * Mark virtual functions as async * Add /network folder * remove lookup test function * Make fetchNode and fetchBlockbook private * Simplify retryWrapper * Add defaultNode * Reset when testnet is toggled * Add missing network filter * Make retryWrapper private * Make safeFetch private * Remove copy/equal pattern * Don't export networkManager * fix typo * Use static member instead of global variable * Make all fields private * Add Missing function callRPC --- scripts/dashboard/CreateWallet.vue | 2 +- scripts/dashboard/Dashboard.vue | 2 +- scripts/global.js | 3 +- scripts/index.js | 2 +- scripts/ledger.js | 2 +- scripts/legacy.js | 2 +- scripts/masternode.js | 2 +- scripts/network.js | 419 ------------------ .../__mocks__/network_manager.js} | 4 +- scripts/network/network.js | 307 +++++++++++++ scripts/network/network_manager.js | 198 +++++++++ scripts/promos.js | 2 +- scripts/settings.js | 26 +- scripts/stake/Stake.vue | 2 +- scripts/wallet.js | 2 +- tests/components/CreateWallet.test.js | 2 +- tests/integration/wallet/sync.spec.js | 4 +- tests/unit/use_wallet.spec.js | 4 +- tests/unit/wallet/signature.spec.js | 2 +- tests/unit/wallet/transactions.spec.js | 2 +- 20 files changed, 534 insertions(+), 455 deletions(-) delete mode 100644 scripts/network.js rename scripts/{__mocks__/network.js => network/__mocks__/network_manager.js} (97%) create mode 100644 scripts/network/network.js create mode 100644 scripts/network/network_manager.js diff --git a/scripts/dashboard/CreateWallet.vue b/scripts/dashboard/CreateWallet.vue index 77ebe1384..ab0b4a14f 100644 --- a/scripts/dashboard/CreateWallet.vue +++ b/scripts/dashboard/CreateWallet.vue @@ -7,7 +7,7 @@ import { ref, watch, toRefs } from 'vue'; import { useWallet } from '../composables/use_wallet.js'; import newWalletIcon from '../../assets/icons/icon-new-wallet.svg'; import Password from '../Password.vue'; -import { getNetwork } from '../network.js'; +import { getNetwork } from '../network/network_manager.js'; const emit = defineEmits(['importWallet']); const showModal = ref(false); diff --git a/scripts/dashboard/Dashboard.vue b/scripts/dashboard/Dashboard.vue index e5ea8c230..a9b881e82 100644 --- a/scripts/dashboard/Dashboard.vue +++ b/scripts/dashboard/Dashboard.vue @@ -28,7 +28,7 @@ import { isColdAddress, isStandardAddress, } from '../misc.js'; -import { getNetwork } from '../network.js'; +import { getNetwork } from '../network/network_manager.js'; import { strHardwareName } from '../ledger'; import { guiAddContactPrompt } from '../contacts-book'; import { scanQRCode } from '../scanner'; diff --git a/scripts/global.js b/scripts/global.js index e1d86567a..6c129c34d 100644 --- a/scripts/global.js +++ b/scripts/global.js @@ -3,7 +3,7 @@ import { TransactionBuilder } from './transaction_builder.js'; import Masternode from './masternode.js'; import { ALERTS, tr, start as i18nStart, translation } from './i18n.js'; import { wallet, hasEncryptedWallet, Wallet } from './wallet.js'; -import { getNetwork } from './network.js'; +import { getNetwork } from './network/network_manager.js'; import { start as settingsStart, strCurrency, @@ -259,7 +259,6 @@ export async function start() { // Register native app service registerWorker(); await settingsStart(); - subscribeToNetworkEvents(); // Make sure we know the correct number of blocks await refreshChainData(); diff --git a/scripts/index.js b/scripts/index.js index 5bacec1b5..a3cec5857 100644 --- a/scripts/index.js +++ b/scripts/index.js @@ -67,6 +67,6 @@ import Masternode from './masternode.js'; export { renderChangelog } from './changelog.js'; export { Masternode }; -export { getNetwork } from './network.js'; +export { getNetwork } from './network/network_manager.js'; export { FlipDown } from './flipdown.js'; diff --git a/scripts/ledger.js b/scripts/ledger.js index 717487b6f..07ace51e3 100644 --- a/scripts/ledger.js +++ b/scripts/ledger.js @@ -1,7 +1,7 @@ import createXpub from 'create-xpub'; import { ALERTS, tr } from './i18n.js'; import { confirmPopup } from './misc.js'; -import { getNetwork } from './network.js'; +import { getNetwork } from './network/network_manager.js'; import { Transaction } from './transaction.js'; import { COIN, cChainParams } from './chain_params.js'; import { hexToBytes, bytesToHex } from './utils.js'; diff --git a/scripts/legacy.js b/scripts/legacy.js index a27983e19..5f5857a14 100644 --- a/scripts/legacy.js +++ b/scripts/legacy.js @@ -7,7 +7,7 @@ import { wallet, getNewAddress } from './wallet.js'; import { cChainParams, COIN, COIN_DECIMALS } from './chain_params.js'; import { generateMasternodePrivkey, confirmPopup } from './misc.js'; import { Database } from './database.js'; -import { getNetwork } from './network.js'; +import { getNetwork } from './network/network_manager.js'; import { ledgerSignTransaction } from './ledger.js'; import { createAlert } from './alerts/alert.js'; diff --git a/scripts/masternode.js b/scripts/masternode.js index 8bb93f561..26df6ab39 100644 --- a/scripts/masternode.js +++ b/scripts/masternode.js @@ -10,7 +10,7 @@ import { OP } from './script.js'; import bs58 from 'bs58'; import base32 from 'base32'; import { isStandardAddress } from './misc.js'; -import { getNetwork } from './network.js'; +import { getNetwork } from './network/network_manager.js'; import { debugError, DebugTopics } from './debug.js'; /** diff --git a/scripts/network.js b/scripts/network.js deleted file mode 100644 index d40f487c8..000000000 --- a/scripts/network.js +++ /dev/null @@ -1,419 +0,0 @@ -import { cChainParams } from './chain_params.js'; -import { debugError, debugLog, DebugTopics } from './debug.js'; -import { isStandardAddress, isXPub } from './misc.js'; -import { sleep } from './utils.js'; -import { getEventEmitter } from './event_bus.js'; -import { setExplorer, fAutoSwitch, setNode } from './settings.js'; -import { cNode } from './settings.js'; -import { Transaction } from './transaction.js'; - -/** - * @typedef {Object} XPUBAddress - * @property {string} type - Type of address (always 'XPUBAddress' for XPUBInfo classes) - * @property {string} name - PIVX address string - * @property {string} path - BIP44 path of the address derivation - * @property {number} transfers - Number of transfers involving the address - * @property {number} decimals - Decimal places in the amounts (PIVX has 8 decimals) - * @property {string} balance - Current balance of the address (satoshi) - * @property {string} totalReceived - Total ever received by the address (satoshi) - * @property {string} totalSent - Total ever sent from the address (satoshi) - */ - -/** - * @typedef {Object} XPUBInfo - * @property {number} page - Current response page in a paginated data - * @property {number} totalPages - Total pages in the paginated data - * @property {number} itemsOnPage - Number of items on the current page - * @property {string} address - XPUB string of the address - * @property {string} balance - Current balance of the xpub (satoshi) - * @property {string} totalReceived - Total ever received by the xpub (satoshi) - * @property {string} totalSent - Total ever sent from the xpub (satoshi) - * @property {string} unconfirmedBalance - Unconfirmed balance of the xpub (satoshi) - * @property {number} unconfirmedTxs - Number of unconfirmed transactions of the xpub - * @property {number} txs - Total number of transactions of the xpub - * @property {string[]?} txids - Transaction ids involving the xpub - * @property {number?} usedTokens - Number of used token addresses from the xpub - * @property {XPUBAddress[]?} tokens - Array of used token addresses - */ - -/** - * Virtual class representing any network backend - * - */ -export class Network { - constructor() { - if (this.constructor === Network) { - throw new Error('Initializing virtual class'); - } - } - - getBlockCount() { - throw new Error('getBlockCount must be implemented'); - } - - getBestBlockHash() { - throw new Error('getBestBlockHash must be implemented'); - } - - sendTransaction() { - throw new Error('sendTransaction must be implemented'); - } - - async getTxInfo(_txHash) { - throw new Error('getTxInfo must be implemented'); - } - - /** - * A safety-wrapped RPC interface for calling Node RPCs with automatic correction handling - * @param {string} api - The API endpoint to call - * @param {boolean} isText - Optionally parse the result as Text rather than JSON - * @returns {Promise} - The RPC response; JSON by default, text if `isText` is true. - */ - async callRPC(api, isText = false) { - const cRes = await retryWrapper(fetchNode, false, api); - return isText ? await cRes.text() : await cRes.json(); - } -} - -/** - * - */ -export class ExplorerNetwork extends Network { - /** - * @param {string} strUrl - Url pointing to the blockbook explorer - */ - constructor(strUrl) { - super(); - /** - * @type{string} - * @public - */ - this.strUrl = strUrl; - } - - /** - * Fetch a block from the current node given the height - * @param {number} blockHeight - * @returns {Promise} the block - */ - async getBlock(blockHeight) { - // First we fetch the blockhash (and strip RPC's quotes) - const strHash = ( - await this.callRPC(`/getblockhash?params=${blockHeight}`, true) - ).replace(/"/g, ''); - // Craft a filter to retrieve only raw Tx hex and txid, also change "tx" to "txs" - const strFilter = - '&filter=' + - encodeURI( - `. | .txs = [.tx[] | { hex: .hex, txid: .txid}] | del(.tx)` - ); - // Fetch the full block (verbose) - return await this.callRPC(`/getblock?params=${strHash},2${strFilter}`); - } - - /** - * Fetch the block height of the current node - * @returns {Promise} - Block height - */ - async getBlockCount() { - return parseInt(await this.callRPC('/getblockcount', true)); - } - - /** - * Fetch the latest block hash of the current explorer or fallback node - * @returns {Promise} - Block hash - */ - async getBestBlockHash() { - try { - // Attempt via Explorer first - const { backend } = await ( - await retryWrapper(fetchBlockbook, true, `/api/v2/api`) - ).json(); - - return backend.bestBlockHash; - } catch { - // Use Nodes as a fallback - return await this.callRPC('/getbestblockhash', true); - } - } - - /** - * Sometimes blockbook might return internal error, in this case this function will sleep for some times and retry - * @param {string} strCommand - The specific Blockbook api to call - * @param {number} sleepTime - How many milliseconds sleep between two calls. Default value is 20000ms - * @returns {Promise} Explorer result in json - */ - async safeFetchFromExplorer(strCommand, sleepTime = 20000) { - let trials = 0; - const maxTrials = 6; - let error; - while (trials < maxTrials) { - trials += 1; - const res = await retryWrapper(fetchBlockbook, true, strCommand); - if (!res.ok) { - try { - error = (await res.json()).error; - } catch (e) { - error = 'Cannot safe fetch from explorer!'; - } - debugLog( - DebugTopics.NET, - 'Blockbook internal error! sleeping for ' + - sleepTime + - ' seconds' - ); - await sleep(sleepTime); - continue; - } - return await res.json(); - } - throw new Error(error); - } - - /** - * Returns the n-th page of transactions belonging to addr - * @param {number} nStartHeight - The minimum transaction block height - * @param {string} addr - a PIVX address or xpub - * @param {number} n - index of the page - * @param {number} pageSize - the maximum number of transactions in the page - * @returns {Promise} - */ - async #getPage(nStartHeight, addr, n, pageSize) { - if (!(isXPub(addr) || isStandardAddress(addr))) { - throw new Error('must provide either a PIVX address or a xpub'); - } - const strRoot = `/api/v2/${isXPub(addr) ? 'xpub/' : 'address/'}${addr}`; - const strCoreParams = `?details=txs&from=${nStartHeight}&pageSize=${pageSize}&page=${n}`; - return await this.safeFetchFromExplorer(strRoot + strCoreParams); - } - - /** - * Returns the n-th page of transactions belonging to addr - * @param {number} nStartHeight - The minimum transaction block height - * @param {string} addr - a PIVX address or xpub - * @param {number} n - index of the page - * @returns {Promise>} - */ - async getTxPage(nStartHeight, addr, n) { - const page = await this.#getPage(nStartHeight, addr, n, 1000); - let txRet = []; - if (page?.transactions?.length > 0) { - for (const tx of page.transactions) { - const parsed = Transaction.fromHex(tx.hex); - parsed.blockHeight = tx.blockHeight; - parsed.blockTime = tx.blockTime; - txRet.push(parsed); - } - } - return txRet; - } - - /** - * Returns the number of pages of transactions belonging to addr - * @param {number} nStartHeight - The minimum transaction block height - * @param {string} addr - a PIVX address or xpub - * @returns {Promise} - */ - async getNumPages(nStartHeight, addr) { - const page = await this.#getPage(nStartHeight, addr, 1, 1); - return page.txs; - } - - /** - * @typedef {object} BlockbookUTXO - * @property {string} txid - The TX hash of the output - * @property {number} vout - The Index Position of the output - * @property {string} value - The string-based satoshi value of the output - * @property {number} height - The block height the TX was confirmed in - * @property {number} confirmations - The depth of the TX in the blockchain - */ - - /** - * Fetch UTXOs from the current primary explorer - * @param {string} strAddress - address of which we want UTXOs - * @returns {Promise>} Resolves when it has finished fetching UTXOs - */ - async getUTXOs(strAddress) { - try { - let publicKey = strAddress; - // Fetch UTXOs for the key - const arrUTXOs = await ( - await retryWrapper( - fetchBlockbook, - true, - `/api/v2/utxo/${publicKey}` - ) - ).json(); - return arrUTXOs; - } catch (e) { - debugError(DebugTopics.NET, e); - } - } - - /** - * Fetch an XPub's basic information - * @param {string} strXPUB - The xpub to fetch info for - * @returns {Promise} - A JSON class of aggregated XPUB info - */ - async getXPubInfo(strXPUB) { - return await ( - await retryWrapper(fetchBlockbook, true, `/api/v2/xpub/${strXPUB}`) - ).json(); - } - - async sendTransaction(hex) { - try { - // Attempt via Explorer first - let strTXID = ''; - try { - const cData = await ( - await retryWrapper( - fetchBlockbook, - true, - '/api/v2/sendtx/', - { - method: 'post', - body: hex, - } - ) - ).json(); - // If there's no TXID, we throw any potential Blockbook errors - if (!cData.result || cData.result.length !== 64) throw cData; - strTXID = cData.result; - } catch { - // Use Nodes as a fallback - strTXID = await this.callRPC( - '/sendrawtransaction?params=' + hex, - true - ); - strTXID = strTXID.replace(/"/g, ''); - } - - // Throw and catch if there's no TXID - if (!strTXID || strTXID.length !== 64) throw strTXID; - - debugLog(DebugTopics.NET, 'Transaction sent! ' + strTXID); - getEventEmitter().emit('transaction-sent', true, strTXID); - return strTXID; - } catch (e) { - getEventEmitter().emit('transaction-sent', false, e); - return false; - } - } - - async getTxInfo(txHash) { - const req = await retryWrapper( - fetchBlockbook, - true, - `/api/v2/tx/${txHash}` - ); - return await req.json(); - } - - /** - * @return {Promise} The list of blocks which have at least one shield transaction - */ - async getShieldBlockList() { - return await this.callRPC('/getshieldblocks'); - } -} - -let _network = null; - -/** - * Sets the network in use by MPW. - * @param {ExplorerNetwork} network - network to use - */ -export function setNetwork(network) { - _network = network; -} - -/** - * Gets the network in use by MPW. - * @returns {ExplorerNetwork?} Returns the network in use, may be null if MPW hasn't properly loaded yet. - */ -export function getNetwork() { - return _network; -} - -/** - * A Fetch wrapper which uses the current Blockbook Network's base URL - * @param {string} api - The specific Blockbook api to call - * @param {RequestInit} options - The Fetch options - * @returns {Promise} - The unresolved Fetch promise - */ -export function fetchBlockbook(api, options) { - return fetch(_network.strUrl + api, options); -} - -/** - * A Fetch wrapper which uses the current Node's base URL - * @param {string} api - The specific Node api to call - * @param {RequestInit} options - The Fetch options - * @returns {Promise} - The unresolved Fetch promise - */ -export function fetchNode(api, options) { - return fetch(cNode.url + api, options); -} - -/** - * A wrapper for Blockbook and Node calls which can, in the event of an unresponsive instance, - * seamlessly attempt the same call on multiple other instances until success. - * @param {Function} func - The function to re-attempt with - * @param {boolean} isExplorer - Whether this is an Explorer or Node call - * @param {...any} args - The arguments to pass to the function - */ -export async function retryWrapper(func, isExplorer, ...args) { - // Track internal errors from the wrapper - let err; - - // Select the instances list to use - Explorers or Nodes - const arrInstances = isExplorer - ? cChainParams.current.Explorers - : cChainParams.current.Nodes; - - // If allowed by the user, Max Tries is ALL MPW-supported instances, otherwise, restrict to only the current one. - let nMaxTries = arrInstances.length + 1; - let retries = 0; - - // The instance index we started at - let nIndex = arrInstances.findIndex((a) => - a.url === isExplorer ? getNetwork().strUrl : cNode.url - ); - - // Run the call until successful, or all attempts exhausted - while (retries < nMaxTries) { - try { - // Call the passed function with the arguments - const res = await func(...args); - - // If the endpoint is non-OK, assume it's an error - if (!res.ok) throw new Error(res.statusText); - - // Return the result if successful - return res; - } catch (error) { - err = error; - - // If allowed, switch instances - if (!fAutoSwitch) throw err; - nIndex = (nIndex + 1) % arrInstances.length; - const cNewInstance = arrInstances[nIndex]; - - if (isExplorer) { - // Set the explorer at Network-class level, then as a hacky workaround for the current callback; we - // ... adjust the internal URL to the new explorer. - await setExplorer(cNewInstance, true); - } else { - // For the Node, we change the setting directly - setNode(cNewInstance, true); - } - - // Bump the attempts, and re-try next loop - retries++; - } - } - - // Throw an error so the calling code knows the operation failed - throw err; -} diff --git a/scripts/__mocks__/network.js b/scripts/network/__mocks__/network_manager.js similarity index 97% rename from scripts/__mocks__/network.js rename to scripts/network/__mocks__/network_manager.js index 073e38096..b86095a45 100644 --- a/scripts/__mocks__/network.js +++ b/scripts/network/__mocks__/network_manager.js @@ -1,6 +1,6 @@ import { vi } from 'vitest'; -import { Transaction } from '../transaction.js'; -import { DebugTopics, debugWarn } from '../debug.js'; +import { Transaction } from '../../transaction.js'; +import { DebugTopics, debugWarn } from '../../debug.js'; export const getNetwork = vi.fn(() => { return globalNetwork; diff --git a/scripts/network/network.js b/scripts/network/network.js new file mode 100644 index 000000000..390286cb3 --- /dev/null +++ b/scripts/network/network.js @@ -0,0 +1,307 @@ +import { isStandardAddress, isXPub } from '../misc.js'; +import { Transaction } from '../transaction.js'; + +/** + * @typedef {Object} XPUBAddress + * @property {string} type - Type of address (always 'XPUBAddress' for XPUBInfo classes) + * @property {string} name - PIVX address string + * @property {string} path - BIP44 path of the address derivation + * @property {number} transfers - Number of transfers involving the address + * @property {number} decimals - Decimal places in the amounts (PIVX has 8 decimals) + * @property {string} balance - Current balance of the address (satoshi) + * @property {string} totalReceived - Total ever received by the address (satoshi) + * @property {string} totalSent - Total ever sent from the address (satoshi) + */ + +/** + * @typedef {Object} XPUBInfo + * @property {number} page - Current response page in a paginated data + * @property {number} totalPages - Total pages in the paginated data + * @property {number} itemsOnPage - Number of items on the current page + * @property {string} address - XPUB string of the address + * @property {string} balance - Current balance of the xpub (satoshi) + * @property {string} totalReceived - Total ever received by the xpub (satoshi) + * @property {string} totalSent - Total ever sent from the xpub (satoshi) + * @property {string} unconfirmedBalance - Unconfirmed balance of the xpub (satoshi) + * @property {number} unconfirmedTxs - Number of unconfirmed transactions of the xpub + * @property {number} txs - Total number of transactions of the xpub + * @property {string[]?} txids - Transaction ids involving the xpub + * @property {number?} usedTokens - Number of used token addresses from the xpub + * @property {XPUBAddress[]?} tokens - Array of used token addresses + */ + +/** + * Virtual class representing any network backend + * + */ +export class Network { + constructor() { + if (this.constructor === Network) { + throw new Error('Initializing virtual class'); + } + } + + async getBlock(blockHeight) { + throw new Error('getBlockCount must be implemented'); + } + + async getTxPage(nStartHeight, addr, n) { + throw new Error('getTxPage must be implemented'); + } + + async getNumPages(nStartHeight, addr) { + throw new Error('getNumPages must be implemented'); + } + + async getUTXOs(strAddress) { + throw new Error('getUTXOs must be implemented'); + } + + async getXPubInfo(strXPUB) { + throw new Error('getXPubInfo must be implemented'); + } + + async getShieldBlockList() { + throw new Error('getShieldBlockList must be implemented'); + } + + async getBlockCount() { + throw new Error('getBlockCount must be implemented'); + } + + async getBestBlockHash() { + throw new Error('getBestBlockHash must be implemented'); + } + + async sendTransaction(hex) { + throw new Error('sendTransaction must be implemented'); + } + + async getTxInfo(_txHash) { + throw new Error('getTxInfo must be implemented'); + } + + // TODO: this should be just a private function of RPCNodeNetwork + // However we use this in masternode.js, let's solve this in a future refactor + async callRPC(api, isText = false) { + throw new Error('callRPC must be implemented'); + } +} + +export class RPCNodeNetwork extends Network { + /** + * @param {string} strUrl - Url pointing to the RPC node + */ + constructor(strUrl) { + super(); + /** + * @type{string} + * @public + */ + this.strUrl = strUrl; + } + /** + * A Fetch wrapper which uses the current Node's base URL + * @param {string} api - The specific Node api to call + * @param {RequestInit?} options - The Fetch options + * @returns {Promise} - The unresolved Fetch promise + */ + #fetchNode(api, options) { + return fetch(this.strUrl + api, options); + } + + async callRPC(api, isText = false) { + const cRes = await this.#fetchNode(api); + return isText ? await cRes.text() : await cRes.json(); + } + + /** + * Fetch a block from the current node given the height + * @param {number} blockHeight + * @returns {Promise} the block + */ + async getBlock(blockHeight) { + // First we fetch the blockhash (and strip RPC's quotes) + const strHash = ( + await this.callRPC(`/getblockhash?params=${blockHeight}`, true) + ).replace(/"/g, ''); + // Craft a filter to retrieve only raw Tx hex and txid, also change "tx" to "txs" + const strFilter = + '&filter=' + + encodeURI( + `. | .txs = [.tx[] | { hex: .hex, txid: .txid}] | del(.tx)` + ); + // Fetch the full block (verbose) + return await this.callRPC(`/getblock?params=${strHash},2${strFilter}`); + } + + /** + * Fetch the block height of the current node + * @returns {Promise} - Block height + */ + async getBlockCount() { + return parseInt(await this.callRPC('/getblockcount', true)); + } + + async getBestBlockHash() { + return await this.callRPC('/getbestblockhash', true); + } + + async sendTransaction(hex) { + // Use Nodes as a fallback + let strTXID = await this.callRPC( + '/sendrawtransaction?params=' + hex, + true + ); + strTXID = strTXID.replace(/"/g, ''); + return { result: strTXID }; + } + + /** + * @return {Promise} The list of blocks which have at least one shield transaction + */ + async getShieldBlockList() { + return await this.callRPC('/getshieldblocks'); + } +} + +/** + * Network realization with a blockbook Explorer + */ +export class ExplorerNetwork extends Network { + /** + * @param {string} strUrl - Url pointing to the blockbook explorer + */ + constructor(strUrl) { + super(); + /** + * @type{string} + * @public + */ + this.strUrl = strUrl; + } + + /** + * Fetch the block height of the current explorer + * @returns {Promise} - Block height + */ + async getBlockCount() { + const req = await this.#fetchBlockbook(`/api/v2/api`); + const { backend } = await req.json(); + return backend.blocks; + } + + /** + * Fetch the latest block hash of the current explorer + * @returns {Promise} - Block hash + */ + async getBestBlockHash() { + const req = await this.#fetchBlockbook(`/api/v2/api`); + const { backend } = await req.json(); + return backend.bestBlockHash; + } + + /** + * Returns the n-th page of transactions belonging to addr + * @param {number} nStartHeight - The minimum transaction block height + * @param {string} addr - a PIVX address or xpub + * @param {number} n - index of the page + * @param {number} pageSize - the maximum number of transactions in the page + * @returns {Promise} + */ + async #getPage(nStartHeight, addr, n, pageSize) { + if (!(isXPub(addr) || isStandardAddress(addr))) { + throw new Error('must provide either a PIVX address or a xpub'); + } + const strRoot = `/api/v2/${isXPub(addr) ? 'xpub/' : 'address/'}${addr}`; + const strCoreParams = `?details=txs&from=${nStartHeight}&pageSize=${pageSize}&page=${n}`; + const req = await this.#fetchBlockbook(strRoot + strCoreParams); + return await req.json(); + } + + /** + * Returns the n-th page of transactions belonging to addr + * @param {number} nStartHeight - The minimum transaction block height + * @param {string} addr - a PIVX address or xpub + * @param {number} n - index of the page + * @returns {Promise>} + */ + async getTxPage(nStartHeight, addr, n) { + const page = await this.#getPage(nStartHeight, addr, n, 1000); + let txRet = []; + if (page?.transactions?.length > 0) { + for (const tx of page.transactions) { + const parsed = Transaction.fromHex(tx.hex); + parsed.blockHeight = tx.blockHeight; + parsed.blockTime = tx.blockTime; + txRet.push(parsed); + } + } + return txRet; + } + + /** + * Returns the number of pages of transactions belonging to addr + * @param {number} nStartHeight - The minimum transaction block height + * @param {string} addr - a PIVX address or xpub + * @returns {Promise} + */ + async getNumPages(nStartHeight, addr) { + const page = await this.#getPage(nStartHeight, addr, 1, 1); + return page.txs; + } + + /** + * @typedef {object} BlockbookUTXO + * @property {string} txid - The TX hash of the output + * @property {number} vout - The Index Position of the output + * @property {string} value - The string-based satoshi value of the output + * @property {number} height - The block height the TX was confirmed in + * @property {number} confirmations - The depth of the TX in the blockchain + */ + + /** + * Fetch UTXOs from the current primary explorer + * @param {string} strAddress - address of which we want UTXOs + * @returns {Promise>} Resolves when it has finished fetching UTXOs + */ + async getUTXOs(strAddress) { + let publicKey = strAddress; + // Fetch UTXOs for the key + const req = await this.#fetchBlockbook(`/api/v2/utxo/${publicKey}`); + return await req.json(); + } + + /** + * Fetch an XPub's basic information + * @param {string} strXPUB - The xpub to fetch info for + * @returns {Promise} - A JSON class of aggregated XPUB info + */ + async getXPubInfo(strXPUB) { + const req = await this.#fetchBlockbook(`/api/v2/xpub/${strXPUB}`); + return await req.json(); + } + + async sendTransaction(hex) { + const req = await this.#fetchBlockbook('/api/v2/sendtx/', { + method: 'post', + body: hex, + }); + return await req.json(); + } + + async getTxInfo(txHash) { + const req = await this.#fetchBlockbook(`/api/v2/tx/${txHash}`); + return await req.json(); + } + + /** + * A Fetch wrapper which uses the current Blockbook Network's base URL + * @param {string} api - The specific Blockbook api to call + * @param {RequestInit?} options - The Fetch options + * @returns {Promise} - The unresolved Fetch promise + */ + #fetchBlockbook(api, options) { + return fetch(this.strUrl + api, options); + } +} diff --git a/scripts/network/network_manager.js b/scripts/network/network_manager.js new file mode 100644 index 000000000..359d4677f --- /dev/null +++ b/scripts/network/network_manager.js @@ -0,0 +1,198 @@ +import { ExplorerNetwork, Network, RPCNodeNetwork } from './network.js'; +import { cChainParams } from '../chain_params.js'; +import { fAutoSwitch } from '../settings.js'; +import { debugLog, DebugTopics, debugWarn } from '../debug.js'; +import { sleep } from '../utils.js'; +import { getEventEmitter } from '../event_bus.js'; + +class NetworkManager { + /** + * @type {Network} - Current selected Explorer + */ + #currentExplorer; + + /** + * @type {Network} - Current selected RPC node + */ + #currentNode; + + /** + * @type {Array} - List of all available Networks + */ + #networks = []; + + start() { + this.#networks = []; + for (let network of cChainParams.current.Explorers) { + this.#networks.push(new ExplorerNetwork(network.url)); + } + for (let network of cChainParams.current.Nodes) { + this.#networks.push(new RPCNodeNetwork(network.url)); + } + } + + /** + * Reset the list of available networks + */ + reset() { + this.#networks = []; + } + + /** + * Sets the network in use by MPW. + * @param {string} strUrl - network to use + * @param {boolean} isRPC - whether we are setting the explorer or the RPC node + */ + setNetwork(strUrl, isRPC) { + if (this.#networks.length === 0) { + this.start(); + } + const found = this.#networks.find( + (network) => network.strUrl === strUrl + ); + if (!found) throw new Error('Cannot find provided Network!'); + if (isRPC) { + this.#currentNode = found; + } else { + this.#currentExplorer = found; + } + } + + /** + * Call all networks until one is succesful + * seamlessly attempt the same call on multiple other instances until success. + * @param {string} funcName - The function to re-attempt with + * @param {boolean} isRPC - Whether to begin with the selected explorer or RPC node + * @param {...any} args - The arguments to pass to the function + */ + async #retryWrapper(funcName, isRPC, ...args) { + let nMaxTries = this.#networks.length; + let attemptNet = isRPC ? this.#currentNode : this.#currentExplorer; + + let i = this.#networks.findIndex((net) => attemptNet === net); + if (i == -1) { + debugWarn(DebugTopics.NET, 'Cannot find index in networks array'); + i = 0; + } + + // Run the call until successful, or all attempts exhausted + for (let attempts = 1; attempts <= nMaxTries; attempts++) { + try { + debugLog( + DebugTopics.NET, + 'attempting ' + funcName + ' on ' + attemptNet.strUrl + ); + const res = await attemptNet[funcName](...args); + return res; + } catch (error) { + debugLog( + DebugTopics.NET, + attemptNet.strUrl + ' failed on ' + funcName + ); + // If allowed, switch instances + if (!fAutoSwitch || attempts == nMaxTries) { + throw error; + } + attemptNet = this.#networks[(i + attempts) % nMaxTries]; + } + } + } + + /** + * Sometimes blockbook might return internal error, in this case this function will sleep for some times and retry + * @param {string} funcName - The function to call + * @param {boolean} isRPC - Whether to begin with the selected explorer or RPC node + * @param {...any} args - The arguments to pass to the function + * @returns {Promise} Explorer result in json + */ + async #safeFetch(funcName, isRPC, ...args) { + let trials = 0; + const sleepTime = 20000; + const maxTrials = 6; + while (trials < maxTrials) { + trials += 1; + try { + return await this.#retryWrapper(funcName, isRPC, ...args); + } catch (e) { + debugLog( + DebugTopics.NET, + 'Blockbook internal error! sleeping for ' + + sleepTime + + ' seconds' + ); + await sleep(sleepTime); + } + } + throw new Error('Cannot safe fetch'); + } + + async getBlock(blockHeight) { + return await this.#safeFetch('getBlock', true, blockHeight); + } + + async getTxPage(nStartHeight, addr, n) { + return await this.#safeFetch('getTxPage', false, nStartHeight, addr, n); + } + + async getNumPages(nStartHeight, addr) { + return await this.#safeFetch('getNumPages', false, nStartHeight, addr); + } + + async getUTXOs(strAddress) { + return await this.#retryWrapper('getUTXOs', false, strAddress); + } + + async getXPubInfo(strXPUB) { + return await this.#retryWrapper('getXPubInfo', false, strXPUB); + } + + async getShieldBlockList() { + return await this.#retryWrapper('getShieldBlockList', true); + } + + async getBlockCount() { + return await this.#retryWrapper('getBlockCount', true); + } + + async getBestBlockHash() { + return await this.#retryWrapper('getBestBlockHash', true); + } + + async sendTransaction(hex) { + try { + const data = await this.#retryWrapper('sendTransaction', true, hex); + + // Throw and catch if the data is not a TXID + if (!data.result || data.result.length !== 64) throw data; + + debugLog(DebugTopics.NET, 'Transaction sent! ' + data.result); + getEventEmitter().emit('transaction-sent', true, data.result); + return data.result; + } catch (e) { + getEventEmitter().emit('transaction-sent', false, e); + return false; + } + } + + async getTxInfo(_txHash) { + return await this.#retryWrapper('getTxInfo', false, _txHash); + } + + async callRPC(api, isText = false) { + return await this.#retryWrapper('callRPC', true, api, isText); + } + + static #instance = new NetworkManager(); + + static getInstance() { + return this.#instance; + } +} + +/** + * Gets the network in use by MPW. + * @returns {NetworkManager} Returns the network manager in use. + */ +export function getNetwork() { + return NetworkManager.getInstance(); +} diff --git a/scripts/promos.js b/scripts/promos.js index 95d98dfd6..74f720735 100644 --- a/scripts/promos.js +++ b/scripts/promos.js @@ -4,7 +4,7 @@ import { doms, restoreWallet, sweepAddress } from './global.js'; import { downloadBlob } from './misc.js'; import { getAlphaNumericRand, arrayToCSV } from './utils.js'; import { ALERTS, translation, tr } from './i18n.js'; -import { getNetwork } from './network.js'; +import { getNetwork } from './network/network_manager.js'; import { scanQRCode } from './scanner.js'; import { createAndSendTransaction } from './legacy.js'; import { UTXO, COutpoint } from './transaction.js'; diff --git a/scripts/settings.js b/scripts/settings.js index 7b92e037a..50181586d 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -7,7 +7,6 @@ import { } from './global.js'; import { wallet, hasEncryptedWallet } from './wallet.js'; import { cChainParams } from './chain_params.js'; -import { setNetwork, ExplorerNetwork, getNetwork } from './network.js'; import { confirmPopup } from './misc.js'; import { switchTranslation, @@ -20,6 +19,7 @@ import { createAlert } from './alerts/alert.js'; import { Database } from './database.js'; import { getEventEmitter } from './event_bus.js'; import countries from 'country-locale-map/countries.json'; +import { getNetwork } from './network/network_manager.js'; // --- Default Settings /** A mode that emits verbose console info for internal MPW operations */ @@ -216,33 +216,26 @@ function subscribeToNetworkEvents() { } // --- Settings Functions -export async function setExplorer(explorer, fSilent = false) { +export async function setExplorer(explorer) { const database = await Database.getInstance(); await database.setSettings({ explorer: explorer.url }); cExplorer = explorer; - - // Enable networking + notify if allowed - if (getNetwork()) { - getNetwork().strUrl = cExplorer.url; - } else { - const network = new ExplorerNetwork(cExplorer.url); - setNetwork(network); - } + getNetwork().setNetwork(cExplorer.url, false); // Update the selector UI doms.domExplorerSelect.value = cExplorer.url; - if (!fSilent) - createAlert( - 'success', - tr(ALERTS.SWITCHED_EXPLORERS, [{ explorerName: cExplorer.name }]), - 2250 - ); + createAlert( + 'success', + tr(ALERTS.SWITCHED_EXPLORERS, [{ explorerName: cExplorer.name }]), + 2250 + ); getEventEmitter().emit('explorer_changed', cExplorer.url); } export async function setNode(node, fSilent = false) { cNode = node; + getNetwork().setNetwork(node.url, true); const database = await Database.getInstance(); database.setSettings({ node: node.url }); @@ -421,6 +414,7 @@ export async function toggleTestnet( // Update testnet toggle in settings doms.domTestnetToggler.checked = cChainParams.current.isTestnet; + getNetwork().reset(); await start(); // Make sure we have the correct number of blocks before loading any wallet await refreshChainData(); diff --git a/scripts/stake/Stake.vue b/scripts/stake/Stake.vue index 7b4c21de7..2ca6318ca 100644 --- a/scripts/stake/Stake.vue +++ b/scripts/stake/Stake.vue @@ -6,7 +6,7 @@ import Activity from '../dashboard/Activity.vue'; import RestoreWallet from '../dashboard/RestoreWallet.vue'; import { Database } from '../database'; import { getEventEmitter } from '../event_bus'; -import { getNetwork } from '../network'; +import { getNetwork } from '../network/network_manager'; import StakeBalance from './StakeBalance.vue'; import StakeInput from './StakeInput.vue'; import { onMounted, ref, watch, nextTick } from 'vue'; diff --git a/scripts/wallet.js b/scripts/wallet.js index 1f5102325..0cd6d912c 100644 --- a/scripts/wallet.js +++ b/scripts/wallet.js @@ -2,7 +2,7 @@ import { validateMnemonic } from 'bip39'; import { decrypt } from './aes-gcm.js'; import { parseWIF } from './encoding.js'; import { beforeUnloadListener, blockCount } from './global.js'; -import { getNetwork } from './network.js'; +import { getNetwork } from './network/network_manager.js'; import { MAX_ACCOUNT_GAP, SHIELD_BATCH_SYNC_SIZE } from './chain_params.js'; import { HistoricalTx, HistoricalTxType } from './historical_tx.js'; import { COutpoint, Transaction } from './transaction.js'; diff --git a/tests/components/CreateWallet.test.js b/tests/components/CreateWallet.test.js index 2a77e6750..47eb76fa9 100644 --- a/tests/components/CreateWallet.test.js +++ b/tests/components/CreateWallet.test.js @@ -5,7 +5,7 @@ import Modal from '../../scripts/Modal.vue'; import { vi, it, describe } from 'vitest'; import 'fake-indexeddb/auto'; -vi.mock('../../scripts/network.js'); +vi.mock('../../scripts/network/network_manager.js'); vi.stubGlobal('indexedDB', new IDBFactory()); describe('create wallet tests', () => { diff --git a/tests/integration/wallet/sync.spec.js b/tests/integration/wallet/sync.spec.js index 20cb4d57c..158820453 100644 --- a/tests/integration/wallet/sync.spec.js +++ b/tests/integration/wallet/sync.spec.js @@ -9,12 +9,12 @@ import { describe, it, vi, expect, afterAll } from 'vitest'; import { getNetwork, resetNetwork, -} from '../../../scripts/__mocks__/network.js'; +} from '../../../scripts/network/__mocks__/network_manager.js'; import { refreshChainData } from '../../../scripts/global.js'; import { COIN } from '../../../scripts/chain_params.js'; import { flushPromises } from '@vue/test-utils'; -vi.mock('../../../scripts/network.js'); +vi.mock('../../../scripts/network/network_manager.js'); /** * @param{import('scripts/wallet').Wallet} wallet - wallet that will generate the transaction diff --git a/tests/unit/use_wallet.spec.js b/tests/unit/use_wallet.spec.js index 57fd63701..24205ebef 100644 --- a/tests/unit/use_wallet.spec.js +++ b/tests/unit/use_wallet.spec.js @@ -4,11 +4,11 @@ import { describe, it, beforeEach, vi } from 'vitest'; import { useWallet } from '../../scripts/composables/use_wallet.js'; import { hasEncryptedWallet, wallet } from '../../scripts/wallet.js'; import { LegacyMasterKey } from '../../scripts/masterkey.js'; -import { getNetwork } from '../../scripts/network.js'; +import { getNetwork } from '../../scripts/network/__mocks__/network_manager.js'; import { strCurrency } from '../../scripts/settings.js'; import { setUpLegacyMainnetWallet } from '../utils/test_utils'; -vi.mock('../../scripts/network.js'); +vi.mock('../../scripts/network/network_manager.js'); describe('useWallet tests', () => { let walletComposable; diff --git a/tests/unit/wallet/signature.spec.js b/tests/unit/wallet/signature.spec.js index 8e70935b2..5319803ad 100644 --- a/tests/unit/wallet/signature.spec.js +++ b/tests/unit/wallet/signature.spec.js @@ -13,7 +13,7 @@ import { } from '../../../scripts/transaction.js'; import { hexToBytes } from '../../../scripts/utils'; -vi.mock('../../../scripts/network.js'); +vi.mock('../../../scripts/network/network_manager.js'); vi.mock('../../../scripts/global.js'); describe('Wallet signature tests', () => { diff --git a/tests/unit/wallet/transactions.spec.js b/tests/unit/wallet/transactions.spec.js index b682ef28e..338926cf6 100644 --- a/tests/unit/wallet/transactions.spec.js +++ b/tests/unit/wallet/transactions.spec.js @@ -14,7 +14,7 @@ import { TransactionBuilder } from '../../../scripts/transaction_builder.js'; vi.stubGlobal('localStorage', { length: 0 }); vi.mock('../../../scripts/global.js'); -vi.mock('../../../scripts/network.js'); +vi.mock('../../../scripts/network/network_manager.js'); /** * @param {Wallet} wallet