From 4ad5a0cafcaf42ce2106adce90d77a2fed70f59b Mon Sep 17 00:00:00 2001 From: JSKitty Date: Mon, 14 Oct 2024 08:00:27 +0100 Subject: [PATCH] Add automatic node switching (#424) * Add automatic node switching * JSDoc typo * Refactor RPC calls to `callRPC` in the Network class * Remove manual Networking from Masternode class * Refactor: remove raw `cExplorer` access from global.js --- scripts/global.js | 6 +-- scripts/index.js | 1 - scripts/masternode.js | 63 ++++++++++-------------- scripts/network.js | 110 ++++++++++++++++++++++++++++++++---------- scripts/settings.js | 5 +- 5 files changed, 118 insertions(+), 67 deletions(-) diff --git a/scripts/global.js b/scripts/global.js index b2234f729..3c2b23546 100644 --- a/scripts/global.js +++ b/scripts/global.js @@ -6,7 +6,6 @@ import { wallet, hasEncryptedWallet, Wallet } from './wallet.js'; import { getNetwork } from './network.js'; import { start as settingsStart, - cExplorer, strCurrency, fAdvancedMode, } from './settings.js'; @@ -389,12 +388,13 @@ export function optimiseCurrencyLocale(nAmount) { * @param {string?} strAddress - Optional address to open, if void, the master key is used */ export async function openExplorer(strAddress = '') { + const strExplorerURL = getNetwork().strUrl; if (wallet.isLoaded() && wallet.isHD() && !strAddress) { const xpub = wallet.getXPub(); - window.open(cExplorer.url + '/xpub/' + xpub, '_blank'); + window.open(strExplorerURL + '/xpub/' + xpub, '_blank'); } else { const address = strAddress || wallet.getAddress(); - window.open(cExplorer.url + '/address/' + address, '_blank'); + window.open(strExplorerURL + '/address/' + address, '_blank'); } } diff --git a/scripts/index.js b/scripts/index.js index c282cb4eb..5bacec1b5 100644 --- a/scripts/index.js +++ b/scripts/index.js @@ -10,7 +10,6 @@ import.meta.webpackContext('@fontsource/montserrat/', { regExp: /\.css$/, }); import './global.js'; -import { getNetwork } from './network.js'; // Export global functions to the MPW namespace so we can use them in html export { diff --git a/scripts/masternode.js b/scripts/masternode.js index 75d769c60..50a0da753 100644 --- a/scripts/masternode.js +++ b/scripts/masternode.js @@ -1,4 +1,3 @@ -import { cNode, cExplorer } from './settings.js'; import { cChainParams, COIN } from './chain_params.js'; import { wallet } from './wallet.js'; import { parseWIF, deriveAddress } from './encoding.js'; @@ -11,6 +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'; /** * Construct a Masternode @@ -49,9 +49,9 @@ export default class Masternode { @return {Promise} The object containing masternode information for this masternode */ async getFullData() { - const strURL = `${cNode.url}/listmasternodes?params=${this.collateralTxId}`; + const strURL = `/listmasternodes?params=${this.collateralTxId}`; try { - const cMasternodes = (await (await fetch(strURL)).json()).filter( + const cMasternodes = (await getNetwork().callRPC(strURL)).filter( (m) => m.outidx === this.outidx ); if (cMasternodes.length > 0) { @@ -186,14 +186,6 @@ export default class Masternode { return bytesToHex(dSHA256(pkt).reverse()); } - /** - * @return {Promise} The last block hash - */ - static async getLastBlockHash() { - const status = await (await fetch(`${cExplorer.url}/api/`)).json(); - return status.backend.bestBlockHash; - } - /** * @return {Promise} The signed message signed with the collateral private key */ @@ -276,7 +268,7 @@ export default class Masternode { */ async broadcastMessageToHex() { const sigTime = Math.round(Date.now() / 1000); - const blockHash = await Masternode.getLastBlockHash(); + const blockHash = await getNetwork().getBestBlockHash(); let ip, port; if (this.addr.includes('.')) { // IPv4 @@ -341,8 +333,8 @@ export default class Masternode { */ async start() { const message = await this.broadcastMessageToHex(); - const url = `${cNode.url}/relaymasternodebroadcast?params=${message}`; - const response = await (await fetch(url)).text(); + const url = `/relaymasternodebroadcast?params=${message}`; + const response = await getNetwork().callRPC(url, true); return response.includes('Masternode broadcast sent'); } @@ -353,8 +345,8 @@ export default class Masternode { * @return {Promise} A list of currently active proposal */ static async getProposals({ fAllowFinished = false } = {}) { - const url = `${cNode.url}/getbudgetinfo`; - let arrProposals = await (await fetch(url)).json(); + const url = `/getbudgetinfo`; + let arrProposals = await getNetwork().callRPC(url); // Apply optional filters if (!fAllowFinished) { @@ -408,9 +400,9 @@ export default class Masternode { const filter = `${encodeURI(filterString)}` + `${this.collateralTxId}-${this.outidx}")`; - const url = `${cNode.url}/getbudgetvotes?params=${proposalName}&filter=${filter}`; + const url = `/getbudgetvotes?params=${proposalName}&filter=${filter}`; try { - const { Vote: vote } = await (await fetch(url)).json(); + const { Vote: vote } = await getNetwork().callRPC(url); return vote === 'YES' ? 1 : 2; } catch (e) { //Cannot parse JSON! This means that you did not vote hence return null @@ -445,12 +437,12 @@ export default class Masternode { voteCode, sigTime ); - const url = `${cNode.url}/mnbudgetrawvote?params=${ - this.collateralTxId - },${this.outidx},${hash},${ - voteCode === 1 ? 'yes' : 'no' - },${sigTime},${encodeURI(signature).replaceAll('+', '%2b')}`; - const text = await (await fetch(url)).text(); + const url = `/mnbudgetrawvote?params=${this.collateralTxId},${ + this.outidx + },${hash},${voteCode === 1 ? 'yes' : 'no'},${sigTime},${encodeURI( + signature + ).replaceAll('+', '%2b')}`; + const text = await getNetwork().callRPC(url, true); return text; } @@ -520,15 +512,14 @@ export default class Masternode { txid, }) { try { - const res = await ( - await fetch( - `${cNode.url}/submitbudget?params=${encodeURI( - name - )},${encodeURI(url)},${nPayments},${start},${encodeURI( - address - )},${monthlyPayment / COIN},${txid}` - ) - ).text(); + const res = await getNetwork().callRPC( + `/submitbudget?params=${encodeURI(name)},${encodeURI( + url + )},${nPayments},${start},${encodeURI(address)},${ + monthlyPayment / COIN + },${txid}`, + true + ); if (/^"[a-f0-9]"$/ && res.length == 64 + 2) { return { ok: true, hash: res }; @@ -552,9 +543,7 @@ export default class Masternode { } static async getNextSuperblock() { - return parseInt( - await (await fetch(`${cNode.url}/getnextsuperblock`)).text() - ); + return parseInt(await getNetwork().callRPC(`/getnextsuperblock`, true)); } /** @@ -562,7 +551,7 @@ export default class Masternode { * @returns {Promise<{total:number, stable:number, enabled:number, inqueue:number, ipv4:number, ipv6:number, onion:number}>} - The masternode count object */ static async getMasternodeCount() { - return await (await fetch(`${cNode.url}/getmasternodecount`)).json(); + return await getNetwork().callRPC('/getmasternodecount'); } /** diff --git a/scripts/network.js b/scripts/network.js index a928bf403..75585c7a2 100644 --- a/scripts/network.js +++ b/scripts/network.js @@ -8,7 +8,7 @@ import { } from './debug.js'; import { sleep } from './utils.js'; import { getEventEmitter } from './event_bus.js'; -import { setExplorer, fAutoSwitch } from './settings.js'; +import { setExplorer, fAutoSwitch, setNode } from './settings.js'; import { cNode } from './settings.js'; import { ALERTS, tr, translation } from './i18n.js'; import { Transaction } from './transaction.js'; @@ -43,7 +43,7 @@ import { Transaction } from './transaction.js'; */ /** - * Virtual class rapresenting any network backend + * Virtual class representing any network backend * */ export class Network { @@ -57,6 +57,10 @@ export class Network { throw new Error('getBlockCount must be implemented'); } + getBestBlockHash() { + throw new Error('getBestBlockHash must be implemented'); + } + sendTransaction() { throw new Error('sendTransaction must be implemented'); } @@ -64,6 +68,17 @@ export class Network { 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(); + } } /** @@ -113,14 +128,30 @@ export class ExplorerNetwork extends Network { return block; } + /** + * Fetch the block height of the current explorer + * @returns {Promise} - Block height + */ async getBlockCount() { const { backend } = await ( - await retryWrapper(fetchBlockbook, `/api/v2/api`) + await retryWrapper(fetchBlockbook, true, `/api/v2/api`) ).json(); return backend.blocks; } + /** + * Fetch the latest block hash of the current explorer + * @returns {Promise} - Block hash + */ + async getBestBlockHash() { + const { backend } = await ( + await retryWrapper(fetchBlockbook, true, `/api/v2/api`) + ).json(); + + return backend.bestBlockHash; + } + /** * 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 @@ -133,7 +164,7 @@ export class ExplorerNetwork extends Network { let error; while (trials < maxTrials) { trials += 1; - const res = await retryWrapper(fetchBlockbook, strCommand); + const res = await retryWrapper(fetchBlockbook, true, strCommand); if (!res.ok) { try { error = (await res.json()).error; @@ -240,7 +271,11 @@ export class ExplorerNetwork extends Network { let publicKey = strAddress; // Fetch UTXOs for the key const arrUTXOs = await ( - await retryWrapper(fetchBlockbook, `/api/v2/utxo/${publicKey}`) + await retryWrapper( + fetchBlockbook, + true, + `/api/v2/utxo/${publicKey}` + ) ).json(); return arrUTXOs; } catch (e) { @@ -255,14 +290,14 @@ export class ExplorerNetwork extends Network { */ async getXPubInfo(strXPUB) { return await ( - await retryWrapper(fetchBlockbook, `/api/v2/xpub/${strXPUB}`) + await retryWrapper(fetchBlockbook, true, `/api/v2/xpub/${strXPUB}`) ).json(); } async sendTransaction(hex) { try { const data = await ( - await retryWrapper(fetchBlockbook, '/api/v2/sendtx/', { + await retryWrapper(fetchBlockbook, true, '/api/v2/sendtx/', { method: 'post', body: hex, }) @@ -281,7 +316,11 @@ export class ExplorerNetwork extends Network { } async getTxInfo(txHash) { - const req = await retryWrapper(fetchBlockbook, `/api/v2/tx/${txHash}`); + const req = await retryWrapper( + fetchBlockbook, + true, + `/api/v2/tx/${txHash}` + ); return await req.json(); } @@ -289,7 +328,7 @@ export class ExplorerNetwork extends Network { * @return {Promise} The list of blocks which have at least one shield transaction */ async getShieldBlockList() { - return await (await fetch(`${cNode.url}/getshieldblocks`)).json(); + return await this.callRPC('/getshieldblocks'); } } @@ -322,22 +361,38 @@ export function fetchBlockbook(api, options) { } /** - * A wrapper for Blockbook calls which can, in the event of an unresponsive explorer, - * seamlessly attempt the same call on multiple other explorers until success. + * 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 */ -async function retryWrapper(func, ...args) { +export async function retryWrapper(func, isExplorer, ...args) { // Track internal errors from the wrapper let err; - // If allowed by the user, Max Tries is ALL MPW-supported explorers, otherwise, restrict to only the current one. - let nMaxTries = cChainParams.current.Explorers.length; + // 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 explorer index we started at - let nIndex = cChainParams.current.Explorers.findIndex( - (a) => a.url === getNetwork().strUrl + // 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 @@ -354,15 +409,20 @@ async function retryWrapper(func, ...args) { } catch (error) { err = error; - // If allowed, switch explorers + // If allowed, switch instances if (!fAutoSwitch) throw err; - nIndex = (nIndex + 1) % cChainParams.current.Explorers.length; - const cNewExplorer = cChainParams.current.Explorers[nIndex]; - - // 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. - getNetwork().strUrl = cNewExplorer.url; - setExplorer(cNewExplorer, true); + 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. + getNetwork().strUrl = cNewInstance.url; + setExplorer(cNewInstance, true); + } else { + // For the Node, we change the setting directly + setNode(cNewInstance, true); + } // Bump the attempts, and re-try next loop retries++; diff --git a/scripts/settings.js b/scripts/settings.js index b48fde7b2..f771536bb 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -238,11 +238,14 @@ export async function setExplorer(explorer, fSilent = false) { ); } -async function setNode(node, fSilent = false) { +export async function setNode(node, fSilent = false) { cNode = node; const database = await Database.getInstance(); database.setSettings({ node: node.url }); + // Update the selector UI + doms.domNodeSelect.value = cNode.url; + if (!fSilent) createAlert( 'success',