diff --git a/package.json b/package.json index 72247f366..5ed3f0e4e 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "html-loader": "^4.2.0", "html-webpack-plugin": "^5.5.0", "mini-css-extract-plugin": "^2.7.2", - "multimap": "^1.1.0", "node-polyfill-webpack-plugin": "^2.0.1", "prettier": "^2.8.1", "raw-loader": "^4.0.2", diff --git a/scripts/__mocks__/global.js b/scripts/__mocks__/global.js deleted file mode 100644 index f321271ae..000000000 --- a/scripts/__mocks__/global.js +++ /dev/null @@ -1,24 +0,0 @@ -import { vi } from 'vitest'; -import { UTXO, COutpoint } from '../transaction.js'; - -const Mempool = vi.fn(); - -Mempool.prototype.reset = vi.fn(); -Mempool.prototype.balance = 0.1 * 10 ** 8; -Mempool.prototype.coldBalance = 0; -Mempool.prototype.isSpent = vi.fn(() => false); -Mempool.prototype.addToOrderedTxMap = vi.fn(); -Mempool.prototype.setSpent = vi.fn(); -Mempool.prototype.updateMempool = vi.fn(); -Mempool.prototype.setBalance = vi.fn(); -Mempool.prototype.getUTXOs = vi.fn(() => [ - new UTXO({ - outpoint: new COutpoint({ - txid: 'f8f968d80ac382a7b64591cc166489f66b7c4422f95fbd89f946a5041d285d7c', - n: 1, - }), - script: '76a914f49b25384b79685227be5418f779b98a6be4c73888ac', - value: 0.1 * 10 ** 8, - }), -]); -export const mempool = new Mempool(); diff --git a/scripts/__mocks__/mempool.js b/scripts/__mocks__/mempool.js new file mode 100644 index 000000000..b7d0a73e1 --- /dev/null +++ b/scripts/__mocks__/mempool.js @@ -0,0 +1,48 @@ +import { vi } from 'vitest'; +import { UTXO, COutpoint } from '../transaction.js'; + +const Mempool = vi.fn(); + +Mempool.prototype.balance = 0.1 * 10 ** 8; +Mempool.prototype.coldBalance = 0; +Mempool.prototype.isSpent = vi.fn(() => false); +Mempool.prototype.setSpent = vi.fn(); +Mempool.prototype.addTransaction = vi.fn(); +Mempool.prototype.getUTXOs = vi.fn(() => [ + new UTXO({ + outpoint: new COutpoint({ + txid: 'f8f968d80ac382a7b64591cc166489f66b7c4422f95fbd89f946a5041d285d7c', + n: 1, + }), + script: '76a914f49b25384b79685227be5418f779b98a6be4c73888ac', + value: 0.1 * 10 ** 8, + }), +]); +Mempool.prototype.outpointToUTXO = vi.fn((outpoint) => { + if ( + outpoint.txid === + 'f8f968d80ac382a7b64591cc166489f66b7c4422f95fbd89f946a5041d285d7c' && + outpoint.n === 1 + ) { + return new UTXO({ + outpoint: new COutpoint({ + txid: 'f8f968d80ac382a7b64591cc166489f66b7c4422f95fbd89f946a5041d285d7c', + n: 1, + }), + script: '76a914f49b25384b79685227be5418f779b98a6be4c73888ac', + value: 0.1 * 10 ** 8, + }); + } +}); +const OutpointState = { + OURS: 1 << 0, // This outpoint is ours + + P2PKH: 1 << 1, // This is a P2PKH outpoint + P2CS: 1 << 2, // This is a P2CS outpoint + + SPENT: 1 << 3, // This outpoint has been spent + IMMATURE: 1 << 4, // Coinbase/coinstake that it's not mature (hence not spendable) yet + LOCKED: 1 << 5, // Coins in the LOCK set +}; + +export { Mempool, OutpointState }; diff --git a/scripts/charting.js b/scripts/charting.js index 30b098d80..dd7d3a64f 100644 --- a/scripts/charting.js +++ b/scripts/charting.js @@ -8,7 +8,7 @@ import { Tooltip, } from 'chart.js'; import { cChainParams, COIN } from './chain_params.js'; -import { doms, mempool } from './global.js'; +import { doms } from './global.js'; import { Database } from './database.js'; import { translation } from './i18n.js'; import { wallet } from './wallet.js'; @@ -45,7 +45,7 @@ async function getWalletDataset() { const arrBreakdown = []; // Public (Available) - const spendable_bal = mempool.balance; + const spendable_bal = wallet.balance; if (spendable_bal > 0) { arrBreakdown.push({ type: translation.chartPublicAvailable, @@ -74,7 +74,7 @@ async function getWalletDataset() { }); } - const immature_bal = mempool.immatureBalance; + const immature_bal = wallet.immatureBalance; if (immature_bal > 0) { arrBreakdown.push({ type: translation.chartImmatureBalance, @@ -83,7 +83,7 @@ async function getWalletDataset() { }); } // Staking (Locked) - const spendable_cold_bal = mempool.coldBalance; + const spendable_cold_bal = wallet.coldBalance; if (spendable_cold_bal > 0) { arrBreakdown.push({ type: 'Staking', diff --git a/scripts/composables/use_wallet.js b/scripts/composables/use_wallet.js index 29f609b2c..077d1fbd4 100644 --- a/scripts/composables/use_wallet.js +++ b/scripts/composables/use_wallet.js @@ -2,7 +2,6 @@ import { getEventEmitter } from '../event_bus.js'; import { hasEncryptedWallet, wallet } from '../wallet.js'; import { ref } from 'vue'; import { strCurrency } from '../settings.js'; -import { mempool } from '../global.js'; import { cMarket } from '../settings.js'; import { ledgerSignTransaction } from '../ledger.js'; @@ -20,6 +19,7 @@ export function useWallet() { const isViewOnly = ref(wallet.isViewOnly()); const getKeyToBackup = async () => await wallet.getKeyToBackup(); const isEncrypted = ref(true); + const loadFromDisk = () => wallet.loadFromDisk(); const hasShield = ref(wallet.hasShield()); // True only iff a shield transaction is being created // Transparent txs are so fast that we don't need to keep track of them. @@ -62,7 +62,7 @@ export function useWallet() { const price = ref(0.0); const sync = async () => { await wallet.sync(); - balance.value = mempool.balance; + balance.value = wallet.balance; shieldBalance.value = await wallet.getShieldBalance(); pendingShieldBalance.value = await wallet.getPendingShieldBalance(); }; @@ -84,13 +84,13 @@ export function useWallet() { } const res = await network.sendTransaction(tx.serialize()); if (res) { - wallet.finalizeTransaction(tx); + wallet.addTransaction(tx); } }; getEventEmitter().on('balance-update', async () => { - balance.value = mempool.balance; - immatureBalance.value = mempool.immatureBalance; + balance.value = wallet.balance; + immatureBalance.value = wallet.immatureBalance; currency.value = strCurrency.toUpperCase(); shieldBalance.value = await wallet.getShieldBalance(); pendingShieldBalance.value = await wallet.getPendingShieldBalance(); @@ -124,5 +124,6 @@ export function useWallet() { price, sync, createAndSendTransaction, + loadFromDisk, }; } diff --git a/scripts/dashboard/Activity.vue b/scripts/dashboard/Activity.vue index 9897e355d..a44dd5514 100644 --- a/scripts/dashboard/Activity.vue +++ b/scripts/dashboard/Activity.vue @@ -2,11 +2,10 @@ import { ref, computed, watch, onMounted } from 'vue'; import { getNetwork } from '../network.js'; import { wallet } from '../wallet.js'; -import { mempool } from '../global.js'; import { COIN, cChainParams } from '../chain_params.js'; import { translation } from '../i18n.js'; import { Database } from '../database.js'; -import { HistoricalTx, HistoricalTxType } from '../mempool'; +import { HistoricalTx, HistoricalTxType } from '../historical_tx.js'; import { getNameOrAddress } from '../contacts-book.js'; import { getEventEmitter } from '../event_bus'; @@ -80,20 +79,20 @@ async function update(txToAdd = 0) { if (txCount < 10 && txToAdd == 0) txToAdd = 10; let found = 0; - const nHeights = Array.from(mempool.orderedTxmap.keys()).sort( - (a, b) => a - b + // Since ECMAScript 2019 .sort is stable. + // https://caniuse.com/mdn-javascript_builtins_array_sort_stable + const orderedTxs = Array.from(wallet.getTransactions()).sort( + (a, b) => a.blockHeight - b.blockHeight ); while (found < txCount + txToAdd) { - if (nHeights.length == 0) { + if (orderedTxs.length == 0) { isHistorySynced.value = true; break; } - const nHeight = nHeights.pop(); - const txsAtnHeight = mempool.orderedTxmap.get(nHeight).filter((tx) => { - return props.rewards ? tx.isCoinStake() : true; - }); - newTxs = newTxs.concat(txsAtnHeight); - found += txsAtnHeight.length; + const tx = orderedTxs.pop(); + if (props.rewards && !tx.isCoinStake()) continue; + newTxs.push(tx); + found++; } const arrTXs = wallet.toHistoricalTXs(newTxs); await parseTXs(arrTXs); diff --git a/scripts/dashboard/Dashboard.vue b/scripts/dashboard/Dashboard.vue index 5c31bfe5b..6e35e3f3a 100644 --- a/scripts/dashboard/Dashboard.vue +++ b/scripts/dashboard/Dashboard.vue @@ -34,7 +34,7 @@ import { updateEncryptionGUI, updateLogOutButton, } from '../global'; -import { mempool, refreshChainData } from '../global.js'; +import { refreshChainData } from '../global.js'; import { confirmPopup, isXPub, diff --git a/scripts/database.js b/scripts/database.js index 6fbcf0940..a153f4dbc 100644 --- a/scripts/database.js +++ b/scripts/database.js @@ -327,38 +327,40 @@ export class Database { const store = this.#db .transaction('txs', 'readonly') .objectStore('txs'); - return (await store.getAll()).map((tx) => { - const vin = tx.vin.map( - (x) => - new CTxIn({ - outpoint: new COutpoint({ - txid: x.outpoint.txid, - n: x.outpoint.n, - }), - scriptSig: x.scriptSig, - sequence: x.sequence, - }) - ); - const vout = tx.vout.map( - (x) => - new CTxOut({ - script: x.script, - value: x.value, - }) - ); - return new Transaction({ - version: tx.version, - blockHeight: tx.blockHeight, - blockTime: tx.blockTime, - vin: vin, - vout: vout, - valueBalance: tx.valueBalance, - shieldSpend: tx.shieldSpend, - shieldOutput: tx.shieldOutput, - bindingSig: tx.bindingSig, - lockTime: tx.lockTime, - }); - }); + return (await store.getAll()) + .map((tx) => { + const vin = tx.vin.map( + (x) => + new CTxIn({ + outpoint: new COutpoint({ + txid: x.outpoint.txid, + n: x.outpoint.n, + }), + scriptSig: x.scriptSig, + sequence: x.sequence, + }) + ); + const vout = tx.vout.map( + (x) => + new CTxOut({ + script: x.script, + value: x.value, + }) + ); + return new Transaction({ + version: tx.version, + blockHeight: tx.blockHeight, + blockTime: tx.blockTime, + vin: vin, + vout: vout, + valueBalance: tx.valueBalance, + shieldSpend: tx.shieldSpend, + shieldOutput: tx.shieldOutput, + bindingSig: tx.bindingSig, + lockTime: tx.lockTime, + }); + }) + .sort((a, b) => a.blockHeight - b.blockHeight); } /** * Remove all txs from db diff --git a/scripts/global.js b/scripts/global.js index 950b2ffae..84d1d16a0 100644 --- a/scripts/global.js +++ b/scripts/global.js @@ -1,9 +1,7 @@ -import { Mempool, UTXO_WALLET_STATE } from './mempool.js'; import { COutpoint } from './transaction.js'; 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 { @@ -45,7 +43,6 @@ export function isLoaded() { } export let doms = {}; -export const mempool = new Mempool(); export const dashboard = createApp(Dashboard).mount('#DashboardTab'); @@ -523,7 +520,7 @@ export async function updatePriceDisplay(domValue, fCold = false) { } export function getBalance(updateGUI = false) { - const nBalance = mempool.balance; + const nBalance = wallet.balance; const nCoins = nBalance / COIN; // Update the GUI too, if chosen @@ -541,7 +538,7 @@ export function getBalance(updateGUI = false) { } export function getStakingBalance(updateGUI = false) { - const nBalance = mempool.coldBalance; + const nBalance = wallet.coldBalance; const nCoins = nBalance / COIN; if (updateGUI) { @@ -565,6 +562,8 @@ export function getStakingBalance(updateGUI = false) { return nBalance; } +getEventEmitter().on('balance-update', () => getStakingBalance(true)); + /** * Fill a 'Coin Amount' with all of a balance type, and update the 'Coin Value' * @param {HTMLInputElement} domCoin - The 'Coin Amount' input element @@ -764,7 +763,6 @@ export async function destroyMasternode() { n: cMasternode.outidx, }) ); - mempool.setBalance(); database.removeMasternode(wallet.getMasterKey()); createAlert('success', ALERTS.MN_DESTROYED, 5000); @@ -828,15 +826,7 @@ export async function importMasternode() { if (!wallet.isHD()) { // Find the first UTXO matching the expected collateral size - const cCollaUTXO = mempool - .getUTXOs({ - filter: UTXO_WALLET_STATE.SPENDABLE, - onlyConfirmed: true, - includeLocked: false, - }) - .find( - (cUTXO) => cUTXO.value === cChainParams.current.collateralInSats - ); + const cCollaUTXO = wallet.getMasternodeUTXOs()[0]; const balance = getBalance(false); // If there's no valid UTXO, exit with a contextual message if (!cCollaUTXO) { @@ -876,11 +866,7 @@ export async function importMasternode() { } else { const path = doms.domMnTxId.value; let masterUtxo; - const utxos = mempool.getUTXOs({ - filter: UTXO_WALLET_STATE.SPENDABLE, - onlyConfirmed: true, - includeLocked: false, - }); + const utxos = wallet.getMasternodeUTXOs(); for (const u of utxos) { if ( u.value === cChainParams.current.collateralInSats && @@ -1722,15 +1708,7 @@ export async function updateMasternodeTab() { doms.masternodeLegacyAccessText; doms.domMnTxId.style.display = 'none'; // Find the first UTXO matching the expected collateral size - const cCollaUTXO = mempool - .getUTXOs({ - filter: UTXO_WALLET_STATE.SPENDABLE, - onlyConfirmed: true, - includeLocked: false, - }) - .find( - (cUTXO) => cUTXO.value === cChainParams.current.collateralInSats - ); + const cCollaUTXO = wallet.getMasternodeUTXOs()[0]; const balance = getBalance(false); if (cMasternode) { @@ -1762,12 +1740,7 @@ export async function updateMasternodeTab() { const mapCollateralPath = new Map(); // Aggregate all valid Masternode collaterals into a map of Path <--> Collateral - for (const cUTXO of mempool.getUTXOs({ - filter: UTXO_WALLET_STATE.SPENDABLE, - onlyConfirmed: true, - includeLocked: false, - })) { - if (cUTXO.value !== cChainParams.current.collateralInSats) continue; + for (const cUTXO of wallet.getMasternodeUTXOs()) { mapCollateralPath.set(wallet.getPath(cUTXO.script), cUTXO); } const fHasCollateral = mapCollateralPath.size > 0; diff --git a/scripts/historical_tx.js b/scripts/historical_tx.js new file mode 100644 index 000000000..92883d9ef --- /dev/null +++ b/scripts/historical_tx.js @@ -0,0 +1,44 @@ +/** + * A historical transaction + */ +export class HistoricalTx { + /** + * @param {HistoricalTxType} type - The type of transaction. + * @param {string} id - The transaction ID. + * @param {Array} receivers - The list of 'output addresses'. + * @param {boolean} shieldedOutputs - If this transaction contains Shield outputs. + * @param {number} time - The block time of the transaction. + * @param {number} blockHeight - The block height of the transaction. + * @param {number} amount - The amount transacted, in coins. + */ + constructor( + type, + id, + receivers, + shieldedOutputs, + time, + blockHeight, + amount + ) { + this.type = type; + this.id = id; + this.receivers = receivers; + this.shieldedOutputs = shieldedOutputs; + this.time = time; + this.blockHeight = blockHeight; + this.amount = amount; + } +} + +/** + * A historical transaction type. + * @enum {number} + */ +export const HistoricalTxType = { + UNKNOWN: 0, + STAKE: 1, + DELEGATION: 2, + UNDELEGATION: 3, + RECEIVED: 4, + SENT: 5, +}; diff --git a/scripts/legacy.js b/scripts/legacy.js index 6a10ddef7..b397376a8 100644 --- a/scripts/legacy.js +++ b/scripts/legacy.js @@ -48,7 +48,7 @@ export async function createAndSendTransaction({ } const res = await getNetwork().sendTransaction(tx.serialize()); if (res) { - wallet.finalizeTransaction(tx); + wallet.addTransaction(tx); return { ok: true, txid: tx.txid }; } return { ok: false }; diff --git a/scripts/mempool.js b/scripts/mempool.js index 111b128d0..ac127f8fc 100644 --- a/scripts/mempool.js +++ b/scripts/mempool.js @@ -1,343 +1,241 @@ -import { getNetwork } from './network.js'; -import { getStakingBalance } from './global.js'; -import { Database } from './database.js'; import { getEventEmitter } from './event_bus.js'; -import Multimap from 'multimap'; -import { wallet } from './wallet.js'; -import { cChainParams } from './chain_params.js'; -import { Account } from './accounts.js'; import { COutpoint, UTXO } from './transaction.js'; -export const UTXO_WALLET_STATE = { - NOT_MINE: 0, // Don't have the key to spend this utxo - SPENDABLE: 1, // Have the key to spend this (P2PKH) utxo - SPENDABLE_COLD: 2, // Have the key to spend this (P2CS) utxo - COLD_RECEIVED: 4, // Have the staking key of this (P2CS) utxo - SPENDABLE_TOTAL: 1 | 2, - IMMATURE: 8, // Coinbase/ coinstake that it's not mature (and hence spendable) yet - LOCKED: 16, // Coins in the LOCK set -}; +export const OutpointState = { + OURS: 1 << 0, // This outpoint is ours -/** - * A historical transaction - */ -export class HistoricalTx { - /** - * @param {HistoricalTxType} type - The type of transaction. - * @param {string} id - The transaction ID. - * @param {Array} receivers - The list of 'output addresses'. - * @param {boolean} shieldedOutputs - If this transaction contains Shield outputs. - * @param {number} time - The block time of the transaction. - * @param {number} blockHeight - The block height of the transaction. - * @param {number} amount - The amount transacted, in coins. - */ - constructor( - type, - id, - receivers, - shieldedOutputs, - time, - blockHeight, - amount - ) { - this.type = type; - this.id = id; - this.receivers = receivers; - this.shieldedOutputs = shieldedOutputs; - this.time = time; - this.blockHeight = blockHeight; - this.amount = amount; - } -} + P2PKH: 1 << 1, // This is a P2PKH outpoint + P2CS: 1 << 2, // This is a P2CS outpoint -/** - * A historical transaction type. - * @enum {number} - */ -export const HistoricalTxType = { - UNKNOWN: 0, - STAKE: 1, - DELEGATION: 2, - UNDELEGATION: 3, - RECEIVED: 4, - SENT: 5, + SPENT: 1 << 3, // This outpoint has been spent + IMMATURE: 1 << 4, // Coinbase/coinstake that it's not mature (hence not spendable) yet + LOCKED: 1 << 5, // Coins in the LOCK set }; -/** A Mempool instance, stores and handles UTXO data for the wallet */ export class Mempool { + /** @type{Map} */ + #outpointStatus = new Map(); + /** - * @type {number} - Immature balance + * Maps txid -> Transaction + * @type{Map} */ - #immatureBalance = 0; + #txmap = new Map(); + /** - * @type {number} - Our Public balance in Satoshis + * balance cache, mapping filter -> balance + * @type{Map} */ - #balance = 0; + #balances = new Map(); + /** - * @type {number} - Our Cold Staking balance in Satoshis + * Add a transaction to the mempool + * And mark the input as spent. + * @param {import('./transaction.js').Transaction} tx */ - #coldBalance = 0; + addTransaction(tx) { + this.#txmap.set(tx.txid, tx); + for (const input of tx.vin) { + this.setSpent(input.outpoint); + } + } + /** - * @type {number} - Highest block height saved on disk + * @param {COutpoint} outpoint */ - #highestSavedHeight = 0; - - constructor() { - /** - * Multimap txid -> spent Coutpoint - * @type {Multimap} - */ - this.spent = new Multimap(); - /** - * A map of all known transactions - * @type {Map} - */ - this.txmap = new Map(); - /** - * Multimap nBlockHeight -> import('./transaction.js').Transaction - * @type {Multimap} - */ - this.orderedTxmap = new Multimap(); + getOutpointStatus(outpoint) { + return this.#outpointStatus.get(outpoint.toUnique()) ?? 0; } - reset() { - this.txmap = new Map(); - this.spent = new Multimap(); - this.orderedTxmap = new Multimap(); - this.setBalance(); - this.#highestSavedHeight = 0; - } - get balance() { - return this.#balance; + /** + * Sets outpoint status to `status`, overriding the old one + * @param {COutpoint} outpoint + * @param {number} status + */ + setOutpointStatus(outpoint, status) { + this.#outpointStatus.set(outpoint.toUnique(), status); + this.#invalidateBalanceCache(); } - get coldBalance() { - return this.#coldBalance; + + /** + * Adds `status` to the outpoint status, keeping the old status + * @param {COutpoint} outpoint + * @param {number} status + */ + addOutpointStatus(outpoint, status) { + const oldStatus = this.#outpointStatus.get(outpoint.toUnique()); + this.#outpointStatus.set(outpoint.toUnique(), oldStatus | status); + this.#invalidateBalanceCache(); } - get immatureBalance() { - return this.#immatureBalance; + + /** + * Removes `status` to the outpoint status, keeping the old status + * @param {COutpoint} outpoint + * @param {number} status + */ + removeOutpointStatus(outpoint, status) { + const oldStatus = this.#outpointStatus.get(outpoint.toUnique()); + this.#outpointStatus.set(outpoint.toUnique(), oldStatus & ~status); + this.#invalidateBalanceCache(); } /** - * An Outpoint to check - * @param {COutpoint} op + * Mark an outpoint as spent + * @param {COutpoint} outpoint */ - isSpent(op) { - return this.spent.get(op.txid)?.some((x) => x.n == op.n); + setSpent(outpoint) { + this.addOutpointStatus(outpoint, OutpointState.SPENT); } /** - * Add a transaction to the orderedTxmap, must be called once a new transaction is received. - * @param {import('./transaction.js').Transaction} tx + * @param {COutpoint} outpoint + * @returns {boolean} whether or not the outpoint has been marked as spent */ - addToOrderedTxMap(tx) { - if (!tx.isConfirmed()) return; - if ( - this.orderedTxmap - .get(tx.blockHeight) - ?.some((x) => x.txid == tx.txid) - ) - return; - this.orderedTxmap.set(tx.blockHeight, tx); + isSpent(outpoint) { + return !!(this.getOutpointStatus(outpoint) & OutpointState.SPENT); } + /** - * Add op to the spent map and optionally remove it from the lock set - * @param {String} txid - transaction id - * @param {COutpoint} op + * Utility function to get the UTXO from an outpoint + * @param {COutpoint} outpoint + * @returns {UTXO?} */ - setSpent(txid, op) { - this.spent.set(txid, op); - if (wallet.isCoinLocked(op)) wallet.unlockCoin(op); + outpointToUTXO(outpoint) { + const tx = this.#txmap.get(outpoint.txid); + if (!tx) return null; + return new UTXO({ + outpoint, + script: tx.vout[outpoint.n].script, + value: tx.vout[outpoint.n].value, + }); } /** + * Get the debit of a transaction in satoshi * @param {import('./transaction.js').Transaction} tx - * @returns {boolean} if the tx is mature */ - isMature(tx) { - if (!(tx.isCoinBase() || tx.isCoinStake())) { - return true; - } - return ( - getNetwork().cachedBlockCount - tx.blockHeight > - cChainParams.current.coinbaseMaturity - ); + getDebit(tx) { + return tx.vin + .filter( + (input) => + this.getOutpointStatus(input.outpoint) & OutpointState.OURS + ) + .map((i) => this.outpointToUTXO(i.outpoint)) + .reduce((acc, u) => acc + (u?.value || 0), 0); } /** - * Get the total wallet balance - * @param {UTXO_WALLET_STATE} filter the filter you want to apply + * Get the credit of a transaction in satoshi + * @param {import('./transaction.js').Transaction} tx */ - getBalance(filter) { - let totBalance = 0; - for (const [_, tx] of this.txmap) { - // Check if tx is mature (or if we want to include immature) - if (!this.isMature(tx) && !(filter & UTXO_WALLET_STATE.IMMATURE)) { - continue; - } - for (let i = 0; i < tx.vout.length; i++) { - const vout = tx.vout[i]; - const outpoint = new COutpoint({ txid: tx.txid, n: i }); - if (this.isSpent(outpoint)) { - continue; - } - const UTXO_STATE = wallet.isMyVout(vout.script); - if ((UTXO_STATE & filter) == 0) { - continue; - } + getCredit(tx) { + const txid = tx.txid; - if ( - !(filter & UTXO_WALLET_STATE.LOCKED) && - wallet.isCoinLocked(outpoint) - ) { - continue; - } - totBalance += vout.value; - } - } - return totBalance; + return tx.vout + .filter( + (_, i) => + this.getOutpointStatus( + new COutpoint({ + txid, + n: i, + }) + ) & OutpointState.OURS + ) + .reduce((acc, u) => acc + u?.value ?? 0, 0); } /** - * Get a list of UTXOs - * @param {Object} o - * @param {Number} o.filter enum element of UTXO_WALLET_STATE - * @param {Number | null} o.target PIVs in satoshi that we want to spend - * @param {Boolean} o.onlyConfirmed Consider only confirmed transactions - * @param {Boolean} o.includeLocked Include locked coins - * @returns {UTXO[]} Array of fetched UTXOs + * @param {object} o - options + * @param {number} [o.filter] - A filter to apply to all UTXOs. For example + * `OutpointState.P2CS` will NOT return P2CS transactions. + * By default it's `OutpointState.SPENT | OutpointState.IMMATURE | OutpointState.LOCKED` + * @param {number} [o.requirement] - A requirement to apply to all UTXOs. For example + * `OutpointState.P2CS` will only return P2CS transactions. + * By default it's MAX_SAFE_INTEGER + * @returns {UTXO[]} a list of unspent transaction outputs */ - getUTXOs({ filter, target, onlyConfirmed = false, includeLocked }) { - let totFound = 0; - let utxos = []; - for (const [_, tx] of this.txmap) { - if (onlyConfirmed && !tx.isConfirmed()) { + getUTXOs({ + filter = OutpointState.SPENT | + OutpointState.IMMATURE | + OutpointState.LOCKED, + requirement = 0, + target = Number.POSITIVE_INFINITY, + } = {}) { + const utxos = []; + let value = 0; + for (const [o, status] of this.#outpointStatus) { + const outpoint = COutpoint.fromUnique(o); + if (status & filter) { continue; } - if (!this.isMature(tx)) { + if ((status & requirement) !== requirement) { continue; } - for (let i = 0; i < tx.vout.length; i++) { - const vout = tx.vout[i]; - const outpoint = new COutpoint({ - txid: tx.txid, - n: i, - }); - if (this.isSpent(outpoint)) { - continue; - } - const UTXO_STATE = wallet.isMyVout(vout.script); - if ((UTXO_STATE & filter) == 0) { - continue; - } - if (!includeLocked && wallet.isCoinLocked(outpoint)) { - continue; - } - utxos.push( - new UTXO({ - outpoint, - script: vout.script, - value: vout.value, - }) - ); - // Return early if you found enough PIVs (11/10 is to make sure to pay fee) - totFound += vout.value; - if (target && totFound > (11 / 10) * target) { - return utxos; - } + utxos.push(this.outpointToUTXO(outpoint)); + value += utxos.at(-1).value; + if (value >= (target * 11) / 10) { + break; } } return utxos; } /** - * Update the mempool status - * @param {import('./transaction.js').Transaction} tx + * @param {number} filter */ - updateMempool(tx) { - if (this.txmap.get(tx.txid)?.isConfirmed()) return; - this.txmap.set(tx.txid, tx); - for (const vin of tx.vin) { - const op = vin.outpoint; - if (!this.isSpent(op)) { - this.setSpent(op.txid, op); - } - } - for (const vout of tx.vout) { - wallet.updateHighestUsedIndex(vout); + getBalance(filter) { + if (this.#balances.has(filter)) { + return this.#balances.get(filter); } - this.addToOrderedTxMap(tx); + const bal = Array.from(this.#outpointStatus) + .filter(([_, status]) => !(status & OutpointState.SPENT)) + .filter(([_, status]) => status & filter) + .reduce((acc, [o]) => { + const outpoint = COutpoint.fromUnique(o); + const tx = this.#txmap.get(outpoint.txid); + return acc + tx.vout[outpoint.n].value; + }, 0); + this.#balances.set(filter, bal); + return bal; } - setBalance() { - this.#balance = this.getBalance(UTXO_WALLET_STATE.SPENDABLE); - this.#coldBalance = this.getBalance(UTXO_WALLET_STATE.SPENDABLE_COLD); - this.#immatureBalance = - this.getBalance( - UTXO_WALLET_STATE.SPENDABLE | UTXO_WALLET_STATE.IMMATURE - ) - this.#balance; - getEventEmitter().emit('balance-update'); - getStakingBalance(true); + #invalidateBalanceCache() { + this.#balances = new Map(); + this.#emitBalanceUpdate(); } - /** - * Save txs on database - */ - async saveOnDisk() { - const nBlockHeights = Array.from(this.orderedTxmap.keys()) - .sort((a, b) => a - b) - .reverse(); - if (nBlockHeights.length == 0) { - return; - } - const database = await Database.getInstance(); - for (const nHeight of nBlockHeights) { - if (this.#highestSavedHeight > nHeight) { - break; - } - await Promise.all( - this.orderedTxmap.get(nHeight).map(async function (tx) { - await database.storeTx(tx); - }) - ); - } - this.#highestSavedHeight = nBlockHeights[0]; + #emittingBalanceUpdate = false; + + #emitBalanceUpdate() { + if (this.#emittingBalanceUpdate) return; + this.#emittingBalanceUpdate = true; + // TODO: This is not ideal, we are limiting the mempool to only emit 1 balance-update per frame, + // but we don't want the mempool to know about animation frames. This is needed during + // sync to avoid spamming balance-updates and slowing down the sync. + // The best course of action is to probably add a loading page/state and avoid + // listening to the balance-update event until the sync is done + requestAnimationFrame(() => { + getEventEmitter().emit('balance-update'); + this.#emittingBalanceUpdate = false; + }); } + /** - * Load txs from database - * @returns {Promise} true if database was non-empty and transaction are loaded successfully + * @returns {import('./transaction.js').Transaction[]} a list of all transactions */ - async loadFromDisk() { - const database = await Database.getInstance(); - // Check if the stored txs are linked to this wallet - if ( - (await database.getAccount())?.publicKey != wallet.getKeyToExport() - ) { - await database.removeAllTxs(); - await database.removeAccount({ publicKey: null }); - const cAccount = new Account({ - publicKey: wallet.getKeyToExport(), - }); - await database.addAccount(cAccount); - return; - } - const txs = await database.getTxs(); - if (txs.length == 0) { - return false; - } - for (const tx of txs) { - this.addToOrderedTxMap(tx); - } - const nBlockHeights = Array.from(this.orderedTxmap.keys()).sort( - (a, b) => a - b - ); - for (const nHeight of nBlockHeights) { - for (const tx of this.orderedTxmap.get(nHeight)) { - this.updateMempool(tx); - } - } - const cNet = getNetwork(); - cNet.lastBlockSynced = nBlockHeights.at(-1); - this.#highestSavedHeight = nBlockHeights.at(-1); - return true; + getTransactions() { + return Array.from(this.#txmap.values()); + } + + get balance() { + return this.getBalance(OutpointState.P2PKH); + } + + get coldBalance() { + return this.getBalance(OutpointState.P2CS); + } + + get immatureBalance() { + return this.getBalance(OutpointState.IMMATURE); } } diff --git a/scripts/network.js b/scripts/network.js index 1c7c5bd82..071e7d9de 100644 --- a/scripts/network.js +++ b/scripts/network.js @@ -12,7 +12,7 @@ import { } from './settings.js'; import { cNode } from './settings.js'; import { ALERTS, tr, translation } from './i18n.js'; -import { mempool, stakingDashboard } from './global.js'; +import { stakingDashboard } from './global.js'; import { Transaction } from './transaction.js'; /** @@ -270,7 +270,7 @@ export class ExplorerNetwork extends Network { // after first sync (so at each new block) we can safely assume that user got less than 1000 new txs //in this way we don't have to fetch the probePage after first sync const txNumber = !this.fullSynced - ? probePage.txs - mempool.txmap.size + ? probePage.txs - this.wallet.getTransactions().length : 1; // Compute the total pages and iterate through them until we've synced everything const totalPages = Math.ceil(txNumber / 1000); @@ -298,13 +298,11 @@ export class ExplorerNetwork extends Network { const parsed = Transaction.fromHex(tx.hex); parsed.blockHeight = tx.blockHeight; parsed.blockTime = tx.blockTime; - mempool.updateMempool(parsed); + await this.wallet.addTransaction(parsed); } } - await mempool.saveOnDisk(); } - mempool.setBalance(); if (debug) { console.log( 'Fetched latest txs: total number of pages was ', @@ -319,12 +317,13 @@ export class ExplorerNetwork extends Network { async walletFullSync() { if (this.fullSynced) return; if (!this.wallet || !this.wallet.isLoaded()) return; + this.lastBlockSynced = Math.max( + ...this.wallet.getTransactions().map((tx) => tx.blockHeight) + ); await this.getLatestTxs(this.lastBlockSynced); - const nBlockHeights = Array.from(mempool.orderedTxmap.keys()); - this.lastBlockSynced = - nBlockHeights.length == 0 - ? 0 - : nBlockHeights.sort((a, b) => a - b).at(-1); + this.lastBlockSynced = Math.max( + ...this.wallet.getTransactions().map((tx) => tx.blockHeight) + ); this.fullSynced = true; getEventEmitter().emit('transparent-sync-status-update', '', true); } diff --git a/scripts/transaction.js b/scripts/transaction.js index d0818cc49..fa3d25d18 100644 --- a/scripts/transaction.js +++ b/scripts/transaction.js @@ -28,6 +28,16 @@ export class COutpoint { toUnique() { return this.txid + this.n.toString(); } + + /** + * @param {string} str + */ + static fromUnique(str) { + return new COutpoint({ + txid: str.slice(0, 64), + n: parseInt(str.slice(64)), + }); + } } export class CTxOut { diff --git a/scripts/wallet.js b/scripts/wallet.js index db4b8070b..ab26d6968 100644 --- a/scripts/wallet.js +++ b/scripts/wallet.js @@ -4,12 +4,11 @@ import { parseWIF } from './encoding.js'; import { beforeUnloadListener } from './global.js'; import { getNetwork } from './network.js'; import { MAX_ACCOUNT_GAP, SHIELD_BATCH_SYNC_SIZE } from './chain_params.js'; -import { HistoricalTx, HistoricalTxType } from './mempool.js'; -import { Transaction } from './transaction.js'; +import { HistoricalTx, HistoricalTxType } from './historical_tx.js'; +import { COutpoint } from './transaction.js'; import { confirmPopup, createAlert, isShieldAddress } from './misc.js'; import { cChainParams } from './chain_params.js'; import { COIN } from './chain_params.js'; -import { mempool } from './global.js'; import { ALERTS, tr, translation } from './i18n.js'; import { encrypt } from './aes-gcm.js'; import { Database } from './database.js'; @@ -18,7 +17,7 @@ import { Account } from './accounts.js'; import { fAdvancedMode } from './settings.js'; import { bytesToHex, hexToBytes, sleep, startBatch } from './utils.js'; import { strHardwareName } from './ledger.js'; -import { UTXO_WALLET_STATE } from './mempool.js'; +import { OutpointState, Mempool } from './mempool.js'; import { getEventEmitter } from './event_bus.js'; import { @@ -89,30 +88,25 @@ export class Wallet { * @type {Boolean} */ #isMainWallet; + /** - * Set of unique representations of Outpoints that keep track of locked utxos. - * @type {Set} - */ - #lockedCoins; - /** - * Whether the wallet is synced - * @type {boolean} + * @type {Mempool} */ + #mempool; + #isSynced = false; - /** - * true iff we are fetching latestBlocks - * @type {boolean} - */ - #isFetchingLatestBlocks; + #isFetchingLatestBlocks = false; + constructor({ - nAccount = 0, - isMainWallet = true, - masterKey = null, - shield = null, - } = {}) { + nAccount, + isMainWallet, + masterKey, + shield, + mempool = new Mempool(), + }) { this.#nAccount = nAccount; this.#isMainWallet = isMainWallet; - this.#lockedCoins = new Set(); + this.#mempool = mempool; this.#masterKey = masterKey; this.#shield = shield; for (let i = 0; i < Wallet.chains; i++) { @@ -124,10 +118,10 @@ export class Wallet { /** * Check whether a given outpoint is locked * @param {import('./transaction.js').COutpoint} opt - * @return {Boolean} true if opt is locked, false otherwise + * @return {boolean} true if opt is locked, false otherwise */ isCoinLocked(opt) { - return this.#lockedCoins.has(opt.toUnique()); + return !!(this.#mempool.getOutpointStatus(opt) & OutpointState.LOCKED); } /** @@ -135,8 +129,7 @@ export class Wallet { * @param {import('./transaction.js').COutpoint} opt */ lockCoin(opt) { - this.#lockedCoins.add(opt.toUnique()); - mempool.setBalance(); + this.#mempool.addOutpointStatus(opt, OutpointState.LOCKED); } /** @@ -144,8 +137,7 @@ export class Wallet { * @param {import('./transaction.js').COutpoint} opt */ unlockCoin(opt) { - this.#lockedCoins.delete(opt.toUnique()); - mempool.setBalance(); + this.#mempool.removeOutpointStatus(opt, OutpointState.LOCKED); } /** @@ -277,11 +269,9 @@ export class Wallet { this.#loadedIndexes.set(i, 0); this.#addressIndices.set(i, 0); } - // TODO: This needs to be refactored - // The wallet could own its own mempool and network? - // Instead of having this isMainWallet flag + this.#mempool = new Mempool(); + // TODO: This needs to be refactored to remove the getNetwork dependency if (this.#isMainWallet) { - mempool.reset(); getNetwork().reset(); } } @@ -481,7 +471,8 @@ export class Wallet { * @return {string?} BIP32 path or null if it's not your address */ isOwnAddress(address) { - return this.#ownAddresses.get(address) ?? null; + const path = this.#ownAddresses.get(address) ?? null; + return path; } /** @@ -520,16 +511,22 @@ export class Wallet { return this.isOwnAddress(address); } - isMyVout(script) { + /** + * Get the outpoint state based on the script. + * This functions only tells us the type of the script and if it's ours + * It doesn't know about LOCK, IMMATURE or SPENT statuses, for that + * it's necessary to interrogate the mempool + */ + getScriptType(script) { const { type, addresses } = this.getAddressesFromScript(script); - const index = addresses.findIndex((s) => this.isOwnAddress(s)); - if (index === -1) return UTXO_WALLET_STATE.NOT_MINE; - if (type === 'p2pkh') return UTXO_WALLET_STATE.SPENDABLE; + let status = 0; + const isOurs = addresses.some((s) => this.isOwnAddress(s)); + if (isOurs) status |= OutpointState.OURS; + if (type === 'p2pkh') status |= OutpointState.P2PKH; if (type === 'p2cs') { - return index === 0 - ? UTXO_WALLET_STATE.COLD_RECEIVED - : UTXO_WALLET_STATE.SPENDABLE_COLD; + status |= OutpointState.P2CS; } + return status; } /** @@ -577,60 +574,15 @@ export class Wallet { return this.#knownPKH.get(pkh_hex); } - /** - * Get the debit of a transaction in satoshi - * @param {import('./transaction.js').Transaction} tx - */ - getDebit(tx) { - let debit = 0; - for (const vin of tx.vin) { - if (mempool.txmap.has(vin.outpoint.txid)) { - const spentVout = mempool.txmap.get(vin.outpoint.txid).vout[ - vin.outpoint.n - ]; - if ( - (this.isMyVout(spentVout.script) & - UTXO_WALLET_STATE.SPENDABLE_TOTAL) != - 0 - ) { - debit += spentVout.value; - } - } - } - return debit; - } - - /** - * Get the credit of a transaction in satoshi - * @param {import('./transaction.js').Transaction} tx - */ - getCredit(tx, filter) { - let credit = 0; - for (const vout of tx.vout) { - if ((this.isMyVout(vout.script) & filter) != 0) { - credit += vout.value; - } - } - return credit; - } - /** * Return true if the transaction contains undelegations regarding the given wallet * @param {import('./transaction.js').Transaction} tx */ checkForUndelegations(tx) { for (const vin of tx.vin) { - if (mempool.txmap.has(vin.outpoint.txid)) { - const spentVout = mempool.txmap.get(vin.outpoint.txid).vout[ - vin.outpoint.n - ]; - if ( - (this.isMyVout(spentVout.script) & - UTXO_WALLET_STATE.SPENDABLE_COLD) != - 0 - ) { - return true; - } + const status = this.#mempool.getOutpointStatus(vin.outpoint); + if (status & OutpointState.P2CS) { + return true; } } return false; @@ -641,11 +593,14 @@ export class Wallet { * @param {import('./transaction.js').Transaction} tx */ checkForDelegations(tx) { - for (const vout of tx.vout) { + const txid = tx.txid; + for (let i = 0; i < tx.vout.length; i++) { + const outpoint = new COutpoint({ + txid, + n: i, + }); if ( - (this.isMyVout(vout.script) & - UTXO_WALLET_STATE.SPENDABLE_COLD) != - 0 + this.#mempool.getOutpointStatus(outpoint) & OutpointState.P2CS ) { return true; } @@ -669,8 +624,8 @@ export class Wallet { /** * Convert a list of Blockbook transactions to HistoricalTxs - * @param {Array} arrTXs - An array of the Blockbook TXs - * @returns {Promise>} - A new array of `HistoricalTx`-formatted transactions + * @param {import('./transaction.js').Transaction[]} arrTXs - An array of the Blockbook TXs + * @returns {Array} - A new array of `HistoricalTx`-formatted transactions */ // TODO: add shield data to txs toHistoricalTXs(arrTXs) { @@ -678,26 +633,39 @@ export class Wallet { for (const tx of arrTXs) { // The total 'delta' or change in balance, from the Tx's sums let nAmount = - (this.getCredit(tx, UTXO_WALLET_STATE.SPENDABLE_TOTAL) - - this.getDebit(tx)) / + (this.#mempool.getCredit(tx) - this.#mempool.getDebit(tx)) / COIN; // The receiver addresses, if any let arrReceivers = this.getOutAddress(tx); + const getFilteredCredit = (filter) => { + return tx.vout + .filter((_, i) => { + const status = this.#mempool.getOutpointStatus( + new COutpoint({ + txid: tx.txid, + n: i, + }) + ); + return status & filter && status & OutpointState.OURS; + }) + .reduce((acc, o) => acc + o.value, 0); + }; + // Figure out the type, based on the Tx's properties let type = HistoricalTxType.UNKNOWN; if (tx.isCoinStake()) { type = HistoricalTxType.STAKE; } else if (this.checkForUndelegations(tx)) { type = HistoricalTxType.UNDELEGATION; + nAmount = getFilteredCredit(OutpointState.P2PKH) / COIN; } else if (this.checkForDelegations(tx)) { type = HistoricalTxType.DELEGATION; arrReceivers = arrReceivers.filter((addr) => { return addr[0] === cChainParams.current.STAKING_PREFIX; }); - nAmount = - this.getCredit(tx, UTXO_WALLET_STATE.SPENDABLE_COLD) / COIN; + nAmount = getFilteredCredit(OutpointState.P2CS) / COIN; } else if (nAmount > 0) { type = HistoricalTxType.RECEIVED; } else if (nAmount < 0) { @@ -726,7 +694,7 @@ export class Wallet { } try { this.#syncing = true; - await mempool.loadFromDisk(); + await this.loadFromDisk(); await this.loadShieldFromDisk(); await getNetwork().walletFullSync(); if (this.hasShield()) { @@ -807,10 +775,9 @@ export class Wallet { * But for now we can just recalculate the UTXOs */ #getUTXOsForShield() { - return mempool + return this.#mempool .getUTXOs({ - filter: UTXO_WALLET_STATE.SPENDABLE, - includeLocked: false, + requirement: OutpointState.P2PKH | OutpointState.OURS, }) .map((u) => { return { @@ -934,11 +901,11 @@ export class Wallet { ) { let balance; if (useDelegatedInputs) { - balance = mempool.coldBalance; + balance = this.#mempool.coldBalance; } else if (useShieldInputs) { balance = this.#shield.getBalance(); } else { - balance = mempool.balance; + balance = this.#mempool.balance; } if (balance < value) { throw new Error('Not enough balance'); @@ -971,10 +938,13 @@ export class Wallet { } if (!useShieldInputs) { - const filter = useDelegatedInputs - ? UTXO_WALLET_STATE.SPENDABLE_COLD - : UTXO_WALLET_STATE.SPENDABLE; - const utxos = mempool.getUTXOs({ filter, target: value }); + const requirement = useDelegatedInputs + ? OutpointState.P2CS + : OutpointState.P2PKH; + const utxos = this.#mempool.getUTXOs({ + requirement: requirement | OutpointState.OURS, + target: value, + }); transactionBuilder.addUTXOs(utxos); // Shield txs will handle change internally @@ -992,7 +962,7 @@ export class Wallet { } else if (changeValue > 0) { // TransactionBuilder will internally add the change only if it is not dust if (!changeAddress) [changeAddress] = this.getNewAddress(1); - if (delegateChange) { + if (delegateChange && changeValue >= 1 * COIN) { transactionBuilder.addColdStakeOutput({ address: changeAddress, value: changeValue, @@ -1091,23 +1061,95 @@ export class Wallet { } /** - * Finalize Transaction. To be called after it's signed and sent to the network, if successful + * Adds a transaction to the mempool. To be called after it's signed and sent to the network, if successful * @param {import('./transaction.js').Transaction} transaction */ - finalizeTransaction(transaction) { + async addTransaction(transaction, skipDatabase = false) { + this.#mempool.addTransaction(transaction); + let i = 0; + for (const out of transaction.vout) { + this.updateHighestUsedIndex(out); + const status = this.getScriptType(out.script); + if (status & OutpointState.OURS) { + this.#mempool.addOutpointStatus( + new COutpoint({ + txid: transaction.txid, + n: i, + }), + status + ); + } + i++; + } + if (transaction.hasShieldData) { - wallet.#shield.finalizeTransaction(transaction.txid); + wallet.#shield?.finalizeTransaction(transaction.txid); } - mempool.updateMempool(transaction); - mempool.setBalance(); + if (!skipDatabase) { + const db = await Database.getInstance(); + await db.storeTx(transaction); + } + } + + /** + * @returns {UTXO[]} Any UTXO that has value of + * exactly `cChainParams.current.collateralInSats` + */ + getMasternodeUTXOs() { + const collateralValue = cChainParams.current.collateralInSats; + return this.#mempool + .getUTXOs({ + requirement: OutpointState.P2PKH | OutpointState.OURS, + }) + .filter((u) => u.value === collateralValue); + } + + /** + * @returns {import('./transaction.js').Transaction[]} a list of all transactions + */ + getTransactions() { + return this.#mempool.getTransactions(); + } + + get balance() { + return this.#mempool.balance; + } + + get immatureBalance() { + return this.#mempool.immatureBalance; + } + + get coldBalance() { + return this.#mempool.coldBalance; + } + + /** + * Utility function to get the UTXO from an outpoint + * @param {COutpoint} outpoint + * @returns {UTXO?} + */ + outpointToUTXO(outpoint) { + return this.#mempool.outpointToUTXO(outpoint); + } + + async loadFromDisk() { + const db = await Database.getInstance(); + if ((await db.getAccount())?.publicKey !== this.getKeyToExport()) { + await db.removeAllTxs(); + return; + } + const txs = await db.getTxs(); + for (const tx of txs) { + this.addTransaction(tx, true); + } } } /** * @type{Wallet} */ -export const wallet = new Wallet(); // For now we are using only the 0-th account, (TODO: update once account system is done) +export const wallet = new Wallet({ nAccountL: 0, isMainWallet: true }); // For now we are using only the 0-th account, (TODO: update once account system is done) /** * Clean a Seed Phrase string and verify it's integrity diff --git a/tests/unit/mempool.spec.js b/tests/unit/mempool.spec.js new file mode 100644 index 000000000..a148833dd --- /dev/null +++ b/tests/unit/mempool.spec.js @@ -0,0 +1,230 @@ +import { it, describe, beforeEach, expect, vi } from 'vitest'; + +import { + Transaction, + CTxOut, + COutpoint, + UTXO, + CTxIn, +} from '../../scripts/transaction.js'; +import { Mempool, OutpointState } from '../../scripts/mempool.js'; + +describe('mempool tests', () => { + /** @type{Mempool} */ + let mempool; + let tx; + beforeEach(() => { + mempool = new Mempool(); + tx = new Transaction({ + version: 1, + vin: [], + vout: [ + new CTxOut({ + script: '76a914f49b25384b79685227be5418f779b98a6be4c73888ac', + value: 4992400, + }), + new CTxOut({ + script: '76a914a95cc6408a676232d61ec29dc56a180b5847835788ac', + value: 5000000, + }), + ], + }); + mempool.addTransaction(tx); + mempool.setOutpointStatus( + new COutpoint({ txid: tx.txid, n: 0 }), + OutpointState.OURS | OutpointState.P2PKH + ); + mempool.setOutpointStatus( + new COutpoint({ txid: tx.txid, n: 1 }), + OutpointState.OURS | OutpointState.P2PKH + ); + }); + + it('gets UTXOs correctly', () => { + let expectedUTXOs = [ + new UTXO({ + outpoint: new COutpoint({ txid: tx.txid, n: 0 }), + script: '76a914f49b25384b79685227be5418f779b98a6be4c73888ac', + value: 4992400, + }), + new UTXO({ + outpoint: new COutpoint({ txid: tx.txid, n: 1 }), + script: '76a914a95cc6408a676232d61ec29dc56a180b5847835788ac', + value: 5000000, + }), + ]; + + // By default, it should return all UTXOs + expect(mempool.getUTXOs()).toStrictEqual(expectedUTXOs); + + // With target, should only return the first one + expect( + mempool.getUTXOs({ + target: 4000000, + }) + ).toStrictEqual([expectedUTXOs[0]]); + + mempool.setSpent(new COutpoint({ txid: tx.txid, n: 0 })); + // After spending one UTXO, it should not return it again + expect(mempool.getUTXOs()).toStrictEqual([expectedUTXOs[1]]); + mempool.setSpent(new COutpoint({ txid: tx.txid, n: 1 })); + expect(mempool.getUTXOs()).toHaveLength(0); + + [0, 1].forEach((n) => + mempool.removeOutpointStatus( + new COutpoint({ txid: tx.txid, n }), + OutpointState.SPENT + ) + ); + mempool.addOutpointStatus( + new COutpoint({ txid: tx.txid, n: 1 }), + OutpointState.LOCKED + ); + // Filter should remove any LOCKED UTXOs + expect( + mempool.getUTXOs({ filter: OutpointState.LOCKED }) + ).toStrictEqual([expectedUTXOs[0]]); + // Requirement should only return LOCKED UTXOs + expect( + mempool.getUTXOs({ + requirement: OutpointState.LOCKED | OutpointState.OURS, + filter: 0, + }) + ).toStrictEqual([expectedUTXOs[1]]); + }); + it('gets correct balance', () => { + expect(mempool.getBalance(OutpointState.P2PKH)).toBe(4992400 + 5000000); + // Subsequent calls should be cached + expect(mempool.balance).toBe(4992400 + 5000000); + expect(mempool.getBalance(OutpointState.P2CS)).toBe(0); + expect( + mempool.getBalance(OutpointState.P2CS | OutpointState.P2PKH) + ).toBe(4992400 + 5000000); + mempool.setSpent(new COutpoint({ txid: tx.txid, n: 0 })); + expect(mempool.getBalance(OutpointState.P2PKH)).toBe(5000000); + mempool.setSpent(new COutpoint({ txid: tx.txid, n: 1 })); + expect(mempool.getBalance(OutpointState.P2PKH)).toBe(0); + }); + + it('gives correct debit', () => { + const spendTx = new Transaction({ + version: 1, + vin: [ + new CTxIn({ + scriptSig: 'dummy', + outpoint: new COutpoint({ + txid: tx.txid, + n: 1, + }), + }), + new CTxIn({ + scriptSig: 'dummy', + outpoint: new COutpoint({ + txid: tx.txid, + n: 0, + }), + }), + ], + vout: [], + }); + mempool.addTransaction(spendTx); + expect(mempool.getDebit(spendTx)).toBe(5000000 + 4992400); + + expect(mempool.getDebit(new Transaction())).toBe(0); + }); + + it('gives correct credit', () => { + expect(mempool.getCredit(tx)).toBe(5000000 + 4992400); + + // Result should stay the same even if the UTXOs are spent + mempool.setSpent(new COutpoint({ txid: tx.txid, n: 1 })); + expect(mempool.getCredit(tx)).toBe(5000000 + 4992400); + mempool.setSpent(new COutpoint({ txid: tx.txid, n: 0 })); + expect(mempool.getCredit(tx)).toBe(5000000 + 4992400); + expect(mempool.getCredit(new Transaction())).toBe(0); + }); + + it('marks outpoint as spent correctly', () => { + const o = [0, 1].map((n) => new COutpoint({ txid: tx.txid, n })); + expect(o.map((out) => mempool.isSpent(out))).toStrictEqual([ + false, + false, + ]); + mempool.setSpent(o[0]); + expect(o.map((out) => mempool.isSpent(out))).toStrictEqual([ + true, + false, + ]); + mempool.setSpent(o[1]); + expect(o.map((out) => mempool.isSpent(out))).toStrictEqual([ + true, + true, + ]); + }); + + it('returns transactions', () => { + expect(mempool.getTransactions()).toStrictEqual([tx]); + }); + + it('correctly handles statuses', () => { + const o = new COutpoint({ txid: tx.txid, n: 0 }); + expect(mempool.getOutpointStatus(o)).toBe( + OutpointState.P2PKH | OutpointState.OURS + ); + // Remove removes one status + mempool.removeOutpointStatus(o, OutpointState.P2PKH); + expect(mempool.getOutpointStatus(o)).toBe(OutpointState.OURS); + mempool.addOutpointStatus(o, OutpointState.P2CS); + expect(mempool.getOutpointStatus(o)).toBe( + OutpointState.P2CS | OutpointState.OURS + ); + // Adding 0 should do nothing + mempool.addOutpointStatus(o, 0); + expect(mempool.getOutpointStatus(o)).toBe( + OutpointState.P2CS | OutpointState.OURS + ); + // Removing 0 should do nothing + mempool.removeOutpointStatus(o, 0); + expect(mempool.getOutpointStatus(o)).toBe( + OutpointState.P2CS | OutpointState.OURS + ); + // Set should override the status + mempool.setOutpointStatus(o, OutpointState.IMMATURE); + expect(mempool.getOutpointStatus(o)).toBe(OutpointState.IMMATURE); + // Add should work with multiple flags + mempool.addOutpointStatus( + o, + OutpointState.P2CS | OutpointState.SPENT | OutpointState.OURS + ); + expect(mempool.getOutpointStatus(o)).toBe( + OutpointState.P2CS | + OutpointState.SPENT | + OutpointState.OURS | + OutpointState.IMMATURE + ); + // Adding an already set flag should do nothing + mempool.addOutpointStatus(o, OutpointState.SPENT); + expect(mempool.getOutpointStatus(o)).toBe( + OutpointState.P2CS | + OutpointState.SPENT | + OutpointState.OURS | + OutpointState.IMMATURE + ); + // Remove should work with multiple flags + mempool.removeOutpointStatus( + o, + OutpointState.LOCKED | OutpointState.P2CS | OutpointState.SPENT + ); + expect(mempool.getOutpointStatus(o)).toBe( + OutpointState.OURS | OutpointState.IMMATURE + ); + // Removing a non set flag should do nothing + mempool.removeOutpointStatus(o, OutpointState.LOCKED); + expect(mempool.getOutpointStatus(o)).toBe( + OutpointState.OURS | OutpointState.IMMATURE + ); + // Removing MAX_SAFE_INTEGER should remove everything + mempool.removeOutpointStatus(o, Number.MAX_SAFE_INTEGER); + expect(mempool.getOutpointStatus(o)).toBe(0); + }); +}); diff --git a/tests/unit/transaction.spec.js b/tests/unit/transaction.spec.js index c791ab448..739a694da 100644 --- a/tests/unit/transaction.spec.js +++ b/tests/unit/transaction.spec.js @@ -114,3 +114,31 @@ describe('transaction tests', () => { } ); }); + +describe('COutpoint tests', () => { + it.each([ + new COutpoint({ + txid: 'c463e9484973e085fac81763f4ba882dad961885890edd02be55b94aa291a739', + n: 123, + }), + new COutpoint({ + txid: '25ffc9a52fe7f4b7b0865d6f5db7aefd17871971ef09fabafe519ceef2174c89', + n: 0, + }), + new COutpoint({ + txid: 'aab36ff5f222ef56f569b5c84d335f522339db8755e5eeae0cee5a447126a9d6', + n: 1, + }), + new COutpoint({ + txid: 'b067b802d4cc673ebf0f124fe4d2304ca5ca7d733fde573011ed29a90dee8e39', + n: 13, + }), + new COutpoint({ + txid: '5be92981ac013a819bd4490aa2f64ef45e4eb0f48abb9e6ed0d5a6cfbf4965f8', + n: 19348113, + }), + ])('converts from and to unique', (outpoint) => { + const unique = outpoint.toUnique(); + expect(COutpoint.fromUnique(unique)).toStrictEqual(outpoint); + }); +}); diff --git a/tests/unit/wallet/signature.spec.js b/tests/unit/wallet/signature.spec.js index c1d4e31d8..5255778be 100644 --- a/tests/unit/wallet/signature.spec.js +++ b/tests/unit/wallet/signature.spec.js @@ -1,5 +1,5 @@ import { Wallet } from '../../../scripts/wallet.js'; -import { getLegacyMainnet, mockMempool } from '../test_utils'; +import { getLegacyMainnet } from '../test_utils'; import { describe, it, vi, afterAll, expect } from 'vitest'; import { COutpoint, @@ -11,7 +11,7 @@ import { import { mempool } from '../../../scripts/global'; import { hexToBytes } from '../../../scripts/utils'; -vi.mock('../../../scripts/global.js'); +vi.mock('../../../scripts/mempool.js'); vi.mock('../../../scripts/network.js'); describe('Wallet signature tests', () => { diff --git a/tests/unit/wallet/transactions.spec.js b/tests/unit/wallet/transactions.spec.js index 27002bfc0..d0008bc8c 100644 --- a/tests/unit/wallet/transactions.spec.js +++ b/tests/unit/wallet/transactions.spec.js @@ -1,28 +1,37 @@ +vi.mock('../../../scripts/mempool.js'); +vi.mock('../../../scripts/network.js'); + import { Wallet } from '../../../scripts/wallet.js'; +import { Mempool } from '../../../scripts/mempool.js'; import { getLegacyMainnet } from '../test_utils'; import { describe, it, vi, afterAll, expect } from 'vitest'; import { COutpoint, CTxIn, CTxOut, - UTXO, Transaction, } from '../../../scripts/transaction.js'; -import { mempool } from '../../../scripts/global'; + +import 'fake-indexeddb/auto'; import { TransactionBuilder } from '../../../scripts/transaction_builder.js'; +vi.stubGlobal('localStorage', { length: 0 }); vi.mock('../../../scripts/global.js'); vi.mock('../../../scripts/network.js'); +/** + * @param {Wallet} wallet + * @param {Transaction} tx + * @param {number} feesPerBytes + */ async function checkFees(wallet, tx, feesPerBytes) { let fees = 0; for (const vout of tx.vout) { fees -= vout.value; } + for (const vin of tx.vin) { - fees += mempool - .getUTXOs() - .find((utxo) => utxo.outpoint.txid === vin.outpoint.txid).value; + fees += wallet.outpointToUTXO(vin.outpoint).value; } // Sign and verify that it pays enough fees, and that it is greedy enough const nBytes = (await wallet.sign(tx)).serialize().length / 2; @@ -31,10 +40,12 @@ async function checkFees(wallet, tx, feesPerBytes) { } describe('Wallet transaction tests', () => { let wallet; + let mempool; let PIVXShield; const MIN_FEE_PER_BYTE = new TransactionBuilder().MIN_FEE_PER_BYTE; beforeEach(() => { - wallet = new Wallet(0, false); + mempool = new Mempool(); + wallet = new Wallet({ nAccount: 0, isMainWallet: false, mempool }); wallet.setMasterKey(getLegacyMainnet()); PIVXShield = vi.fn(); PIVXShield.prototype.createTransaction = vi.fn(() => { @@ -44,6 +55,9 @@ describe('Wallet transaction tests', () => { }); PIVXShield.prototype.getBalance = vi.fn(() => 40 * 10 ** 8); wallet.setShield(new PIVXShield()); + // Reset indexedDB before each test + vi.stubGlobal('indexedDB', new IDBFactory()); + return vi.unstubAllGlobals; }); it('Creates a transaction correctly', async () => { const tx = wallet.createTransaction( @@ -60,11 +74,8 @@ describe('Wallet transaction tests', () => { scriptSig: '76a914f49b25384b79685227be5418f779b98a6be4c73888ac', // Script sig must be the UTXO script since it's not signed }) ); - expect(tx.vout[1]).toStrictEqual( - new CTxOut({ - script: '76a914f49b25384b79685227be5418f779b98a6be4c73888ac', - value: 4997730, - }) + expect(tx.vout[1].script).toBe( + '76a914f49b25384b79685227be5418f779b98a6be4c73888ac' ); expect(tx.vout[0]).toStrictEqual( new CTxOut({ @@ -106,7 +117,6 @@ describe('Wallet transaction tests', () => { }); it('Creates a tx with change address', async () => { - const wallet = new Wallet(0, false); wallet.setMasterKey(getLegacyMainnet()); const tx = wallet.createTransaction( 'EXMDbnWT4K3nWfK1311otFrnYLcFSipp3iez', @@ -375,11 +385,9 @@ describe('Wallet transaction tests', () => { it('finalizes transaction correctly', () => { const tx = new Transaction(); - wallet.finalizeTransaction(tx); - expect(mempool.updateMempool).toBeCalled(1); - expect(mempool.updateMempool).toBeCalledWith(tx); - expect(mempool.setBalance).toBeCalled(1); - expect(mempool.setBalance).toBeCalledWith(); + wallet.addTransaction(tx); + expect(mempool.addTransaction).toBeCalled(1); + expect(mempool.addTransaction).toBeCalledWith(tx); }); afterAll(() => {