diff --git a/cypress/snapshots/html/snapshot.html b/cypress/snapshots/html/snapshot.html index 84773d778..30dad255d 100644 --- a/cypress/snapshots/html/snapshot.html +++ b/cypress/snapshots/html/snapshot.html @@ -1,440 +1,440 @@ -
TimeDescriptionAmount
11/07/24Block Reward4.00 PIV +
TimeDescriptionAmount
Nov 7Block Reward4.00 PIV -
11/07/24Sent to DJYKS3...780 PIV +
Nov 7Sent to DJYKS3...780 PIV -
11/06/24Delegated to SdgQDp...500 PIV +
Nov 6Delegated to SdgQDp...500 PIV -
11/04/24Sent to D9m8Q9...10.00 PIV +
Nov 4Sent to D9m8Q9...10.00 PIV -
11/04/24Sent to D9m8Q9...5.00 PIV +
Nov 4Sent to D9m8Q9...5.00 PIV -
11/04/24Sent to D9m8Q9...2.00 PIV +
Nov 4Sent to D9m8Q9...2.00 PIV -
11/02/24Block Reward4.00 PIV +
Nov 2Block Reward4.00 PIV -
11/01/24Block Reward4.00 PIV +
Nov 1Block Reward4.00 PIV -
10/31/24Delegated to SdgQDp...500 PIV +
Oct 31Delegated to SdgQDp...500 PIV -
10/31/24Delegated to SdgQDp...500 PIV +
Oct 31Delegated to SdgQDp...500 PIV -
10/31/24Delegated to SdgQDp...500 PIV +
Oct 31Delegated to SdgQDp...500 PIV -
10/31/24Delegated to SdgQDp...500 PIV +
Oct 31Delegated to SdgQDp...500 PIV -
10/31/24Delegated to SdgQDp...500 PIV +
Oct 31Delegated to SdgQDp...500 PIV -
10/31/24Delegated to SdgQDp...500 PIV +
Oct 31Delegated to SdgQDp...500 PIV -
10/29/24Block Reward4,250 PIV +
Oct 29Block Reward4,250 PIV -
10/28/24Sent to D8pLwc...52.83 PIV +
Oct 28Sent to D8pLwc...52.83 PIV -
10/28/24Undelegated100 PIV +
Oct 28Undelegated100 PIV -
10/28/24Block Reward4.00 PIV +
Oct 28Block Reward4.00 PIV -
10/25/24Sent to DDS5v6...575 PIV +
Oct 25Sent to DDS5v6...575 PIV -
10/25/24Undelegated575 PIV +
Oct 25Undelegated575 PIV -
10/24/24Block Reward4.00 PIV +
Oct 24Block Reward4.00 PIV -
10/20/24Block Reward4.00 PIV +
Oct 20Block Reward4.00 PIV -
10/12/24Block Reward4.00 PIV +
Oct 12Block Reward4.00 PIV -
10/11/24Sent to DQHLhj...1,250 PIV +
Oct 11Sent to DQHLhj...1,250 PIV -
10/11/24Undelegated1,250 PIV +
Oct 11Undelegated1,250 PIV -
10/11/24Received with DLabsk...0.10 PIV +
Oct 11Received with DLabsk...0.10 PIV -
10/08/24Block Reward4.00 PIV +
Oct 8Block Reward4.00 PIV -
10/05/24Block Reward4.00 PIV +
Oct 5Block Reward4.00 PIV -
10/04/24Sent to DLh3sF...485 PIV +
Oct 4Sent to DLh3sF...485 PIV -
10/04/24Sent to DB4AWX...1.00 PIV +
Oct 4Sent to DB4AWX...1.00 PIV -
10/03/24Sent to DPFMBW...264 PIV +
Oct 3Sent to DPFMBW...264 PIV -
10/03/24Undelegated500 PIV +
Oct 3Undelegated500 PIV -
10/03/24Undelegated250 PIV +
Oct 3Undelegated250 PIV -
10/02/24Delegated to SdgQDp...4,250 PIV +
Oct 2Delegated to SdgQDp...4,250 PIV -
09/30/24Sent to self0.01 PIV +
Sep 30Sent to self0.01 PIV -
09/28/24Block Reward4,250 PIV +
Sep 28Block Reward4,250 PIV -
09/19/24Sent to DHYYDV...747 PIV +
Sep 19Sent to DHYYDV...747 PIV -
09/15/24Sent to DQzW1G...100 PIV +
Sep 15Sent to DQzW1G...100 PIV -
09/06/24Sent to D6robL...300 PIV +
Sep 6Sent to D6robL...300 PIV -
09/05/24Sent to D5WAwQ...1,500 PIV +
Sep 5Sent to D5WAwQ...1,500 PIV -
09/05/24Undelegated2,646 PIV +
Sep 5Undelegated2,646 PIV -
09/04/24Sent to DEsey2...3,500 PIV +
Sep 4Sent to DEsey2...3,500 PIV -
09/04/24Undelegated3,501 PIV +
Sep 4Undelegated3,501 PIV -
09/03/24Sent to DHprhh...106 PIV +
Sep 3Sent to DHprhh...106 PIV -
09/03/24Undelegated106 PIV +
Sep 3Undelegated106 PIV -
09/03/24Sent to DHprhh...101 PIV +
Sep 3Sent to DHprhh...101 PIV -
09/03/24Undelegated101 PIV +
Sep 3Undelegated101 PIV -
09/02/24Sent to DPf384...100 PIV +
Sep 2Sent to DPf384...100 PIV -
09/02/24Undelegated100 PIV +
Sep 2Undelegated100 PIV -
09/02/24Sent to DLAoq6...1,050 PIV +
Sep 2Sent to DLAoq6...1,050 PIV -
09/02/24Undelegated1,050 PIV +
Sep 2Undelegated1,050 PIV -
09/02/24Block Reward4.00 PIV +
Sep 2Block Reward4.00 PIV -
09/01/24Delegated to SdgQDp...7,500 PIV +
Sep 1Delegated to SdgQDp...7,500 PIV -
08/29/24Sent to DP7Vf5...700 PIV +
Aug 29Sent to DP7Vf5...700 PIV -
08/29/24Sent to D9AbTj...1,500 PIV +
Aug 29Sent to D9AbTj...1,500 PIV -
08/29/24Block Reward7,500 PIV +
Aug 29Block Reward7,500 PIV -
08/29/24Block Reward2,200 PIV +
Aug 29Block Reward2,200 PIV -
08/14/24Sent to DQzW1G...150 PIV +
Aug 14Sent to DQzW1G...150 PIV -
08/09/24Sent to DLNq1j...700 PIV +
Aug 9Sent to DLNq1j...700 PIV -
08/03/24Sent to D8Frux...1,100 PIV +
Aug 3Sent to D8Frux...1,100 PIV -
08/03/24Undelegated1,000 PIV +
Aug 3Undelegated1,000 PIV -
07/29/24Sent to DQHajg...6,240 PIV +
Jul 29Sent to DQHajg...6,240 PIV -
07/29/24Sent to DQHajg...10.00 PIV +
Jul 29Sent to DQHajg...10.00 PIV -
07/29/24Delegated to SdgQDp...500 PIV +
Jul 29Delegated to SdgQDp...500 PIV -
07/29/24Delegated to SdgQDp...500 PIV +
Jul 29Delegated to SdgQDp...500 PIV -
07/29/24Sent to DQzW1G...1,000 PIV +
Jul 29Sent to DQzW1G...1,000 PIV -
07/29/24Sent to D7joLL...11,750 PIV +
Jul 29Sent to D7joLL...11,750 PIV -
07/29/24Block Reward6,250 PIV +
Jul 29Block Reward6,250 PIV -
07/29/24Block Reward12,500 PIV +
Jul 29Block Reward12,500 PIV -
07/29/24Block Reward2,200 PIV +
Jul 29Block Reward2,200 PIV -
07/23/24Sent to DURpVc...203 PIV +
Jul 23Sent to DURpVc...203 PIV -
07/23/24Sent to D8Frux...500 PIV +
Jul 23Sent to D8Frux...500 PIV -
07/23/24Undelegated554 PIV +
Jul 23Undelegated554 PIV -
07/21/24Sent to D9HDmW...300 PIV +
Jul 21Sent to D9HDmW...300 PIV -
07/19/24Block Reward4.00 PIV +
Jul 19Block Reward4.00 PIV -
07/18/24Sent to DQzW1G...51.00 PIV +
Jul 18Sent to DQzW1G...51.00 PIV -
07/18/24Undelegated500 PIV +
Jul 18Undelegated500 PIV -
07/14/24Block Reward4.00 PIV +
Jul 14Block Reward4.00 PIV -
07/12/24Sent to DHQvLM...148 PIV +
Jul 12Sent to DHQvLM...148 PIV -
07/11/24Sent to DQzW1G...50.00 PIV +
Jul 11Sent to DQzW1G...50.00 PIV -
07/10/24Sent to DQzW1G...50.00 PIV +
Jul 10Sent to DQzW1G...50.00 PIV -
07/09/24Sent to DJ383G...1,252 PIV +
Jul 9Sent to DJ383G...1,252 PIV -
07/09/24Undelegated1,500 PIV +
Jul 9Undelegated1,500 PIV -
07/09/24Block Reward4.00 PIV +
Jul 9Block Reward4.00 PIV -
07/08/24Block Reward4.00 PIV +
Jul 8Block Reward4.00 PIV -
07/03/24Block Reward4.00 PIV +
Jul 3Block Reward4.00 PIV -
06/30/24Delegated to SdgQDp...2,534 PIV +
Jun 30Delegated to SdgQDp...2,534 PIV -
06/30/24Received with DLabsk...0.25 PIV +
Jun 30Received with DLabsk...0.25 PIV -
06/30/24Received with DLabsk...1.06 PIV +
Jun 30Received with DLabsk...1.06 PIV -
06/28/24Block Reward2,200 PIV +
Jun 28Block Reward2,200 PIV -
06/28/24Received with DLabsk...2.09 PIV +
Jun 28Received with DLabsk...2.09 PIV -
06/28/24Received with DLabsk...1.95 PIV +
Jun 28Received with DLabsk...1.95 PIV -
06/27/24Received with DLabsk...0.49 PIV +
Jun 27Received with DLabsk...0.49 PIV -
06/27/24Received with DLabsk...10.35 PIV +
Jun 27Received with DLabsk...10.35 PIV -
06/27/24Received with DLabsk...0.65 PIV +
Jun 27Received with DLabsk...0.65 PIV -
06/27/24Received with DLabsk...1.06 PIV +
Jun 27Received with DLabsk...1.06 PIV -
06/26/24Received with DLabsk...0.64 PIV +
Jun 26Received with DLabsk...0.64 PIV -
06/26/24Received with DLabsk...0.65 PIV +
Jun 26Received with DLabsk...0.65 PIV -
06/25/24Received with DLabsk...6.27 PIV +
Jun 25Received with DLabsk...6.27 PIV -
06/24/24Received with DLabsk...1.46 PIV +
Jun 24Received with DLabsk...1.46 PIV -
06/24/24Received with DLabsk...0.10 PIV +
Jun 24Received with DLabsk...0.10 PIV -
06/24/24Sent to D9AbTj...21.00 PIV +
Jun 24Sent to D9AbTj...21.00 PIV -
06/24/24Received with DLabsk...0.26 PIV +
Jun 24Received with DLabsk...0.26 PIV -
06/24/24Received with DLabsk...1.29 PIV +
Jun 24Received with DLabsk...1.29 PIV -
06/24/24Received with DLabsk...4.01 PIV +
Jun 24Received with DLabsk...4.01 PIV -
06/24/24Received with DLabsk...0.28 PIV +
Jun 24Received with DLabsk...0.28 PIV -
06/24/24Received with DLabsk...2.15 PIV +
Jun 24Received with DLabsk...2.15 PIV -
06/23/24Received with DLabsk...1.04 PIV +
Jun 23Received with DLabsk...1.04 PIV -
06/22/24Received with DLabsk...2.58 PIV +
Jun 22Received with DLabsk...2.58 PIV -
06/22/24Received with DLabsk...1.28 PIV +
Jun 22Received with DLabsk...1.28 PIV diff --git a/scripts/dashboard/Activity.vue b/scripts/dashboard/Activity.vue index 4a096035a..fc3fdf2bc 100644 --- a/scripts/dashboard/Activity.vue +++ b/scripts/dashboard/Activity.vue @@ -8,11 +8,11 @@ import { Database } from '../database.js'; import { HistoricalTx, HistoricalTxType } from '../historical_tx.js'; import { getNameOrAddress } from '../contacts-book.js'; import { getEventEmitter } from '../event_bus'; -import { beautifyNumber } from '../misc'; import iCheck from '../../assets/icons/icon-check.svg'; import iHourglass from '../../assets/icons/icon-hourglass.svg'; import { blockCount } from '../global.js'; +import { beautifyNumber } from '../misc.js'; const props = defineProps({ title: String, @@ -69,6 +69,39 @@ const txMap = computed(() => { }; }); +/** + * Returns the information that we need to show (icon + label + amount) for a self transaction + * @param {number} amount - The net amount of transparent PIVs in a transaction + * @param {number} shieldAmount - The net amount of shielded PIVs in a transaction + */ +function txSelfMap(amount, shieldAmount) { + if (shieldAmount == 0 || amount == 0) { + return { + icon: 'fa-recycle', + colour: 'white', + content: + shieldAmount == 0 + ? translation.activitySentTo + : 'Shield sent to self', + amount: Math.abs(shieldAmount + amount), + }; + } else if (shieldAmount > 0) { + return { + icon: 'fa-shield', + colour: 'white', + content: 'Shielding', + amount: shieldAmount, + }; + } else if (shieldAmount < 0) { + return { + icon: 'fa-shield', + colour: 'white', + content: 'De-Shielding', + amount: amount, + }; + } +} + function updateReward() { if (!props.rewards) return; let res = 0; @@ -78,6 +111,7 @@ function updateReward() { } rewardAmount.value = res; } + async function update(txToAdd = 0) { // Return if wallet is not synced yet if (!wallet.isSynced) { @@ -125,30 +159,51 @@ async function parseTXs(arrTXs) { const newTxs = []; // Prepare time formatting - const dateOptions = { - year: '2-digit', - month: '2-digit', - day: '2-digit', - }; const timeOptions = { hour: '2-digit', minute: '2-digit', hour12: true, }; + const dateOptions = { + month: 'short', + day: 'numeric', + }; + const yearOptions = { + month: 'short', + day: 'numeric', + year: '2-digit', + }; const cDB = await Database.getInstance(); const cAccount = await cDB.getAccount(); + const cDate = new Date(); for (const cTx of arrTXs) { - const dateTime = new Date(cTx.time * 1000); - // If this Tx is older than 24h, then hit the `Date` cache logic, otherwise, use a `Time` and skip it - let strDate = - Date.now() / 1000 - cTx.time > 86400 - ? dateTime.toLocaleDateString(undefined, dateOptions) - : dateTime.toLocaleTimeString(undefined, timeOptions); - if (cTx.blockHeight === -1) { - strDate = 'Pending'; + const cTxDate = new Date(cTx.time * 1000); + + // Unconfirmed Txs are simply 'Pending' + let strDate = 'Pending'; + if (cTx.blockHeight !== -1) { + // Check if it was today (same day, month and year) + const fToday = + cTxDate.getDate() === cDate.getDate() && + cTxDate.getMonth() === cDate.getMonth() && + cTxDate.getFullYear() === cDate.getFullYear(); + + // Figure out the most convenient time display for this Tx + if (fToday) { + // TXs made today are displayed by time (02:13 pm) + strDate = cTxDate.toLocaleTimeString(undefined, timeOptions); + } else if (cTxDate.getFullYear() === cDate.getFullYear()) { + // TXs older than today are displayed by short date (18 Nov) + strDate = cTxDate.toLocaleDateString(undefined, dateOptions); + } else { + // TXs in previous years are displayed by their short date and year (18 Nov 2023) + strDate = cTxDate.toLocaleDateString(undefined, yearOptions); + } } + let amountToShow = Math.abs(cTx.amount + cTx.shieldAmount); + // Coinbase Transactions (rewards) require coinbaseMaturity confs let fConfirmed = cTx.blockHeight > 0 && @@ -157,47 +212,20 @@ async function parseTXs(arrTXs) { ? cChainParams.current.coinbaseMaturity : 6); - // Choose the content type, for the Dashboard; use a generative description, otherwise, a TX-ID - // let txContent = props.rewards ? cTx.id : 'Block Reward'; - - // Format the amount to reduce text size - let formattedAmt = ''; - if (cTx.amount < 0.01) { - formattedAmt = beautifyNumber('0.01', '13px'); - } else if (cTx.amount >= 100) { - formattedAmt = beautifyNumber( - Math.round(cTx.amount).toString(), - '13px' - ); - } else { - formattedAmt = beautifyNumber(`${cTx.amount.toFixed(2)}`, '13px'); - } - - // For 'Send' TXs: Check if this is a send-to-self transaction - let fSendToSelf = false; - if (cTx.type === HistoricalTxType.SENT) { - fSendToSelf = true; - // Check all addresses to find our own, caching them for performance - for (const strAddr of cTx.receivers) { - // If a previous Tx checked this address, skip it, otherwise, check it against our own address(es) - if (!wallet.isOwnAddress(strAddr)) { - // External address, this is not a self-only Tx - fSendToSelf = false; - } - } - } - // Take the icon, colour and content based on the type of the transaction let { icon, colour, content } = txMap.value[cTx.type]; const match = content.match(/{(.)}/); if (match) { let who = ''; - if (fSendToSelf) { + if (cTx.isToSelf && cTx.type !== HistoricalTxType.DELEGATION) { who = translation.activitySelf; - } else if (cTx.shieldedOutputs) { - who = translation.activityShieldedAddress; + const descriptor = txSelfMap(cTx.amount, cTx.shieldAmount); + icon = descriptor.icon; + colour = descriptor.colour; + content = descriptor.content; + amountToShow = descriptor.amount; } else { - const arrAddresses = cTx.receivers + let arrAddresses = cTx.receivers .map((addr) => [wallet.isOwnAddress(addr), addr]) .filter(([isOwnAddress, _]) => { return cTx.type === HistoricalTxType.RECEIVED @@ -205,6 +233,9 @@ async function parseTXs(arrTXs) { : !isOwnAddress; }) .map(([_, addr]) => getNameOrAddress(cAccount, addr)); + if (cTx.type == HistoricalTxType.RECEIVED) { + arrAddresses = arrAddresses.concat(cTx.shieldReceivers); + } who = [ ...new Set( @@ -215,16 +246,37 @@ async function parseTXs(arrTXs) { ) ), ].join(', ') + '...'; + if ( + cTx.type == HistoricalTxType.SENT && + arrAddresses.length == 0 + ) { + // We sent a shield note to someone, but we cannot decrypt the recipient + // So show a generic "Sent to shield address" + who = translation.activityShieldedAddress; + } } content = content.replace(/{.}/, who); } + // Format the amount to reduce text size + let formattedAmt = ''; + if (amountToShow < 0.01) { + formattedAmt = beautifyNumber('0.01', '13px'); + } else if (amountToShow >= 100) { + formattedAmt = beautifyNumber( + Math.round(amountToShow).toString(), + '13px' + ); + } else { + formattedAmt = beautifyNumber(`${amountToShow.toFixed(2)}`, '13px'); + } + newTxs.push({ date: strDate, id: cTx.id, content: props.rewards ? cTx.id : content, formattedAmt, - amount: cTx.amount, + amount: amountToShow, confirmed: fConfirmed, icon, colour, diff --git a/scripts/historical_tx.js b/scripts/historical_tx.js index 90ac30c4a..83c3446f9 100644 --- a/scripts/historical_tx.js +++ b/scripts/historical_tx.js @@ -6,27 +6,34 @@ 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 {Array} shieldReceivers - The list of decrypted 'shield output addresses'. * @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. + * @param {number} amount - The transparent amount transacted, in coins. + * @param {number} shieldAmount - The shielded amount transacted, in coins. + * @param {boolean} isToSelf - If the transaction is to self. + * @param {boolean} isConfirmed - Whether the transaction has been confirmed. */ constructor( type, id, receivers, - shieldedOutputs, + shieldReceivers, time, blockHeight, - amount + amount, + shieldAmount, + isToSelf ) { this.type = type; this.id = id; this.receivers = receivers; - this.shieldedOutputs = shieldedOutputs; + this.shieldReceivers = shieldReceivers; this.time = time; this.blockHeight = blockHeight; this.amount = amount; + this.shieldAmount = shieldAmount; + this.isToSelf = isToSelf; } } diff --git a/scripts/mempool.js b/scripts/mempool.js index f5b9f12bd..93812e118 100644 --- a/scripts/mempool.js +++ b/scripts/mempool.js @@ -117,13 +117,15 @@ export class Mempool { * @param {import('./transaction.js').Transaction} tx */ getDebit(tx) { - return tx.vin - .filter( - (input) => - this.getOutpointStatus(input.outpoint) & OutpointState.OURS - ) + const filteredVin = tx.vin.filter( + (input) => + this.getOutpointStatus(input.outpoint) & OutpointState.OURS + ); + const debit = filteredVin .map((i) => this.outpointToUTXO(i.outpoint)) .reduce((acc, u) => acc + (u?.value || 0), 0); + const ownAllVin = tx.vin.length === filteredVin.length; + return { debit, ownAllVin }; } /** @@ -133,17 +135,21 @@ export class Mempool { getCredit(tx) { const txid = tx.txid; - return tx.vout - .filter( - (_, i) => - this.getOutpointStatus( - new COutpoint({ - txid, - n: i, - }) - ) & OutpointState.OURS - ) - .reduce((acc, u) => acc + u?.value ?? 0, 0); + const filteredVout = tx.vout.filter( + (_, i) => + this.getOutpointStatus( + new COutpoint({ + txid, + n: i, + }) + ) & OutpointState.OURS + ); + const credit = filteredVout.reduce((acc, u) => acc + u?.value ?? 0, 0); + const ownAllVout = tx.vout.length === filteredVout.length; + return { + credit, + ownAllVout, + }; } /** diff --git a/scripts/reader.js b/scripts/reader.js new file mode 100644 index 000000000..7fc70595f --- /dev/null +++ b/scripts/reader.js @@ -0,0 +1,99 @@ +export class Reader { + #i = 0; + #maxBytes = 0; + #availableBytes; + #done = false; + /** + * @type{()=>{} | null} Called when bytes are available. + * There can't be more than 1 awaiter + */ + #awaiter = null; + + /** + * @returns {number} Content length if available, or an estimante + */ + get contentLength() { + return this.#availableBytes.length; + } + + /** + * @returns {number} Number or bytes read + */ + get readBytes() { + return this.#i; + } + /** + * @param + */ + constructor(req) { + this.#availableBytes = new Uint8Array( + req.headers?.get('Content-Length') || 1024 + ); + const stream = req.body.getReader(); + (async () => { + while (true) { + const { done, value } = await stream.read(); + if (value) { + this.#appendBytes(value); + } + if (done) { + this.#done = true; + if (this.#awaiter) this.#awaiter(); + break; + } + } + })(); + } + + #resizeArray(newLength) { + if (newLength <= this.#availableBytes.length) { + throw new Error( + 'New length must be greater than the current length.' + ); + } + + const newArray = new Uint8Array(newLength); + newArray.set(this.#availableBytes); + this.#availableBytes = newArray; + } + + #appendBytes(bytes) { + // If we have content-length, there should never be a need to + // resize + if (bytes.length + this.#maxBytes > this.#availableBytes.length) { + this.#resizeArray((bytes.length + this.#maxBytes) * 2); + } + + this.#availableBytes.set(bytes, this.#maxBytes); + this.#maxBytes += bytes.length; + // Notify the awaiter if there is one + if (this.#awaiter) this.#awaiter(); + } + + /** + * @param{number} byteLength + * @returns {Promise} bytes or null if there are no more bytes + */ + async read(byteLength) { + if (this.#awaiter) throw new Error('Called read more than once'); + while (true) { + if (this.#maxBytes - this.#i >= byteLength) { + this.#awaiter = null; + // We have enough bytes to respond + const res = this.#availableBytes.subarray( + this.#i, + this.#i + byteLength + ); + this.#i += byteLength; + return res; + } + + // There are no more bytes to await, so we can return null + if (this.#done) return null; + // If we didn't respond, wait for the next batch of bytes, then try again + await new Promise((res) => { + this.#awaiter = res; + }); + } + } +} diff --git a/scripts/utils.js b/scripts/utils.js index 79d1b6397..db2343757 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -13,6 +13,10 @@ export function bytesToHex(bytes) { return Buffer.from(bytes).toString('hex'); } +export function reverseAndSwapEndianess(hex) { + return bytesToHex(hexToBytes(hex).reverse()); +} + /** * Double SHA256 hash a byte array * @param {Array} buff - Bytes to hash diff --git a/scripts/wallet.js b/scripts/wallet.js index 1fd5ef567..e06e9d2b5 100644 --- a/scripts/wallet.js +++ b/scripts/wallet.js @@ -1,4 +1,5 @@ import { validateMnemonic } from 'bip39'; +import { Reader } from './reader.js'; import { decrypt } from './aes-gcm.js'; import { bytesToNum, parseWIF } from './encoding.js'; import { beforeUnloadListener, blockCount } from './global.js'; @@ -15,7 +16,12 @@ import { Database } from './database.js'; import { RECEIVE_TYPES } from './contacts-book.js'; import { Account } from './accounts.js'; import { fAdvancedMode } from './settings.js'; -import { bytesToHex, hexToBytes, sleep } from './utils.js'; +import { + bytesToHex, + hexToBytes, + reverseAndSwapEndianess, + sleep, +} from './utils.js'; import { strHardwareName } from './ledger.js'; import { OutpointState, Mempool } from './mempool.js'; import { getEventEmitter } from './event_bus.js'; @@ -34,7 +40,13 @@ import { guiToggleReceiveType } from './contacts-book.js'; import { TransactionBuilder } from './transaction_builder.js'; import { createAlert } from './alerts/alert.js'; import { AsyncInterval } from './async_interval.js'; -import { debugError, debugLog, DebugTopics } from './debug.js'; +import { + debugError, + debugLog, + debugTimerEnd, + debugTimerStart, + DebugTopics, +} from './debug.js'; import { OrderedArray } from './ordered_array.js'; /** @@ -639,20 +651,23 @@ export class Wallet { /** * Convert a list of Blockbook transactions to HistoricalTxs * @param {import('./transaction.js').Transaction[]} arrTXs - An array of the Blockbook TXs - * @returns {Array} - A new array of `HistoricalTx`-formatted transactions + * @returns {Promise>} - A new array of `HistoricalTx`-formatted transactions */ - // TODO: add shield data to txs - toHistoricalTXs(arrTXs) { + async toHistoricalTXs(arrTXs) { let histTXs = []; for (const tx of arrTXs) { + const { credit, ownAllVout } = this.#mempool.getCredit(tx); + const { debit, ownAllVin } = this.#mempool.getDebit(tx); // The total 'delta' or change in balance, from the Tx's sums - let nAmount = - (this.#mempool.getCredit(tx) - this.#mempool.getDebit(tx)) / - COIN; + let nAmount = (credit - debit) / COIN; + // Shielded data + const { shieldCredit, shieldDebit, arrShieldReceivers } = + await this.extractSaplingAmounts(tx); + const nShieldAmount = (shieldCredit - shieldDebit) / COIN; + const ownAllShield = shieldDebit - shieldCredit === tx.valueBalance; // The receiver addresses, if any let arrReceivers = this.getOutAddress(tx); - const getFilteredCredit = (filter) => { return tx.vout .filter((_, i) => { @@ -682,9 +697,9 @@ export class Wallet { return addr[0] === cChainParams.current.STAKING_PREFIX; }); nAmount = getFilteredCredit(OutpointState.P2CS) / COIN; - } else if (nAmount > 0) { + } else if (nAmount + nShieldAmount > 0) { type = HistoricalTxType.RECEIVED; - } else if (nAmount < 0) { + } else if (nAmount + nShieldAmount < 0) { type = HistoricalTxType.SENT; } @@ -693,10 +708,12 @@ export class Wallet { type, tx.txid, arrReceivers, - false, + arrShieldReceivers, tx.blockTime, tx.blockHeight, - Math.abs(nAmount) + nAmount, + nShieldAmount, + ownAllVin && ownAllVout && ownAllShield ) ); } @@ -704,10 +721,38 @@ export class Wallet { } /** + * Extract the sapling spent, received and shield addressed, regarding the wallet, from a tx + * @param {import('./transaction.js').Transaction} tx - a Transaction object + */ + async extractSaplingAmounts(tx) { + let shieldCredit = 0; + let shieldDebit = 0; + let arrShieldReceivers = []; + if (!tx.hasShieldData || !wallet.hasShield()) { + return { shieldCredit, shieldDebit, arrShieldReceivers }; + } + + for (const shieldSpend of tx.shieldSpend) { + const nullifier = reverseAndSwapEndianess(shieldSpend.nullifier); + const spentNote = this.#shield.getNoteFromNullifier(nullifier); + if (spentNote) { + shieldDebit += spentNote.value; + } + } + const myOutputNotes = await this.#shield.decryptTransactionOutputs( + tx.serialize() + ); + for (const note of myOutputNotes) { + shieldCredit += note.value; + arrShieldReceivers.push(note.recipient); + } + return { shieldCredit, shieldDebit, arrShieldReceivers }; + } + /* * @param {Transaction} tx */ - #pushToHistoricalTx(tx) { - const hTx = this.toHistoricalTXs([tx])[0]; + async #pushToHistoricalTx(tx) { + const hTx = (await this.toHistoricalTXs([tx]))[0]; this.#historicalTxs.insert(hTx); } @@ -726,15 +771,17 @@ export class Wallet { getEventEmitter().disableEvent('balance-update'); getEventEmitter().disableEvent('new-tx'); - await this.loadFromDisk(); await this.loadShieldFromDisk(); + await this.loadFromDisk(); // Let's set the last processed block 5 blocks behind the actual chain tip // This is just to be sure since blockbook (as we know) // usually does not return txs of the actual last block. this.#lastProcessedBlock = blockCount - 5; await this.#transparentSync(); if (this.hasShield()) { + debugTimerStart(DebugTopics.WALLET, 'syncShield'); await this.#syncShield(); + debugTimerEnd(DebugTopics.WALLET, 'syncShield'); } this.#isSynced = true; // At this point download the last missing blocks in the range (blockCount -5, blockCount] @@ -787,100 +834,101 @@ export class Wallet { wallet.#shield.getLastSyncedBlock() + 1 ); if (!req.ok) throw new Error("Couldn't sync shield"); - const reader = req.body.getReader(); + const reader = new Reader(req); /** @type{string[]} Array of txs in the current block */ let txs = []; - let processedBytes = 0; - const length = req.headers.get('Content-Length'); + const length = reader.contentLength; /** @type {Uint8Array} Array of bytes that we are processing **/ - const processing = new Uint8Array(length); getEventEmitter().emit( 'shield-sync-status-update', 0, length, false ); - let i = 0; - let max = 0; - while (true) { - /** - * @type {{done: boolean, value: Uint8Array?}} - */ - const { done, value } = await reader.read(); - /** - * Array of blocks ready to pass to the shield library - * @type {{txs: string[]; height: number; time: number}[]} - */ - const blocksArray = []; - - if (value) { - // Append received bytes in the processing array - processing.set(value, max); - max += value.length; - processedBytes += value.length; - // Loop until we have less than 4 bytes (length) - while (max - i >= 4) { - const length = Number( - bytesToNum(processing.subarray(i, i + 4)) - ); - // If we have less bytes than the length break and wait for the next - // batch of bytes - if (max - i < length) break; - - i += 4; - const bytes = processing.subarray(i, length + i); - i += length; - // 0x5d rapresents the block - if (bytes[0] === 0x5d) { - const height = Number( - bytesToNum(bytes.slice(1, 5)) - ); - const time = Number(bytesToNum(bytes.slice(5, 9))); - - blocksArray.push({ txs, height, time }); - txs = []; - } else if (bytes[0] === 0x03) { - // 0x03 is the tx version. We should only get v3 transactions - const hex = bytesToHex(bytes); - txs.push({ - hex, - txid: Transaction.getTxidFromHex(hex), - }); - } else { - // This is neither a block or a tx. - throw new Error('Failed to parse shield binary'); - } - } - } + /** + * Array of blocks ready to pass to the shield library + * @type {{txs: string[]; height: number; time: number}[]} + */ + let blocksArray = []; + let handleBlocksTime = 0; + const handleAllBlocks = async () => { + const start = performance.now(); // Process the current batch of blocks before starting to parse the next one if (blocksArray.length) { - await this.#shield.handleBlocks(blocksArray); + const ownTxs = await this.#shield.handleBlocks(blocksArray); + // TODO: slow! slow! slow! + if (ownTxs.length > 0) { + for (const block of blocksArray) { + for (const tx of block.txs) { + if (ownTxs.includes(tx.hex)) { + const parsed = Transaction.fromHex(tx.hex); + parsed.blockTime = block.time; + parsed.blockHeight = block.height; + await this.addTransaction(parsed); + } + } + } + } } + handleBlocksTime += performance.now() - start; + blocksArray = []; // Emit status update getEventEmitter().emit( 'shield-sync-status-update', - processedBytes, + reader.readBytes, length, false ); - if (done) break; + }; + while (true) { + const packetLengthBytes = await reader.read(4); + if (!packetLengthBytes) break; + const packetLength = Number(bytesToNum(packetLengthBytes)); + + const bytes = await reader.read(packetLength); + if (!bytes) throw new Error('Stream was cut short'); + if (bytes[0] === 0x5d) { + const height = Number(bytesToNum(bytes.slice(1, 5))); + const time = Number(bytesToNum(bytes.slice(5, 9))); + + blocksArray.push({ txs, height, time }); + txs = []; + } else if (bytes[0] === 0x03) { + // 0x03 is the tx version. We should only get v3 transactions + const hex = bytesToHex(bytes); + txs.push({ + hex, + txid: Transaction.getTxidFromHex(hex), + }); + } else { + // This is neither a block or a tx. + throw new Error('Failed to parse shield binary'); + } + if (blocksArray.length >= 10) { + await handleAllBlocks(); + } } - - getEventEmitter().emit('shield-sync-status-update', 0, 0, true); + await handleAllBlocks(); + debugLog( + DebugTopics.WALLET, + `syncShield rust internal ${handleBlocksTime} ms` + ); + // At this point it should be safe to assume that shield is ready to use + await this.saveShieldOnDisk(); } catch (e) { debugError(DebugTopics.WALLET, e); } - // At this point it should be safe to assume that shield is ready to use - await this.saveShieldOnDisk(); const networkSaplingRoot = ( await getNetwork().getBlock(this.#shield.getLastSyncedBlock()) ).finalsaplingroot; if (networkSaplingRoot) await this.#checkShieldSaplingRoot(networkSaplingRoot); this.#isSynced = true; + + getEventEmitter().emit('shield-sync-status-update', 0, 0, true); } /** @@ -934,25 +982,7 @@ export class Wallet { ) { try { block = await cNet.getBlock(blockHeight); - if (block.txs) { - if ( - this.hasShield() && - blockHeight > this.#shield.getLastSyncedBlock() - ) { - await this.#shield.handleBlock(block); - } - for (const tx of block.txs) { - const parsed = Transaction.fromHex(tx.hex); - parsed.blockHeight = blockHeight; - parsed.blockTime = block.mediantime; - // Avoid wasting memory on txs that do not regard our wallet - if (this.ownTransaction(parsed)) { - await this.addTransaction(parsed); - } - } - } else { - break; - } + await this.#handleBlock(block, blockHeight); this.#lastProcessedBlock = blockHeight; } catch (e) { debugError(DebugTopics.WALLET, e); @@ -974,15 +1004,15 @@ export class Wallet { ); async #checkShieldSaplingRoot(networkSaplingRoot) { - const saplingRoot = bytesToHex( - hexToBytes(await this.#shield.getSaplingRoot()).reverse() + const saplingRoot = reverseAndSwapEndianess( + await this.#shield.getSaplingRoot() ); // If explorer sapling root is different from ours, there must be a sync error if (saplingRoot !== networkSaplingRoot) { createAlert('warning', translation.badSaplingRoot, 5000); this.#mempool = new Mempool(); - this.#isSynced = false; await this.#resetShield(); + this.#isSynced = false; await this.#transparentSync(); await this.#syncShield(); return false; @@ -1029,6 +1059,7 @@ export class Wallet { ); await this.#resetShield(); } + return; } async #resetShield() { @@ -1271,14 +1302,39 @@ export class Wallet { const db = await Database.getInstance(); await db.storeTx(transaction); } - if (tx) { this.#historicalTxs.remove((hTx) => hTx.id === tx.txid); } - this.#pushToHistoricalTx(transaction); + await this.#pushToHistoricalTx(transaction); getEventEmitter().emit('new-tx'); } + /** + * Handle the various transactions of a block + * @param block - block outputted from any PIVX node + * @param {number} blockHeight - the height of the block in the chain + * @param {boolean} allowOwn - whether to add transaction that satisfy ownTransaction() + */ + async #handleBlock(block, blockHeight, allowOwn = true) { + let shieldTxs = []; + if ( + this.hasShield() && + blockHeight > this.#shield.getLastSyncedBlock() + ) { + shieldTxs = await this.#shield.handleBlock(block); + } + for (const tx of block.txs) { + const parsed = Transaction.fromHex(tx.hex); + parsed.blockHeight = blockHeight; + parsed.blockTime = block.time; + // Avoid wasting memory on txs that do not regard our wallet + const isOwned = allowOwn ? this.ownTransaction(parsed) : false; + if (isOwned || shieldTxs.includes(tx.hex)) { + await this.addTransaction(parsed); + } + } + } + /** * Check if any vin or vout of the transaction belong to the wallet * @param {import('./transaction.js').Transaction} transaction diff --git a/tests/unit/mempool.spec.js b/tests/unit/mempool.spec.js index 2294f7883..f41370cca 100644 --- a/tests/unit/mempool.spec.js +++ b/tests/unit/mempool.spec.js @@ -121,20 +121,20 @@ describe('mempool tests', () => { vout: [], }); mempool.addTransaction(spendTx); - expect(mempool.getDebit(spendTx)).toBe(5000000 + 4992400); + expect(mempool.getDebit(spendTx).debit).toBe(5000000 + 4992400); - expect(mempool.getDebit(new Transaction())).toBe(0); + expect(mempool.getDebit(new Transaction()).debit).toBe(0); }); it('gives correct credit', () => { - expect(mempool.getCredit(tx)).toBe(5000000 + 4992400); + expect(mempool.getCredit(tx).credit).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); + expect(mempool.getCredit(tx).credit).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); + expect(mempool.getCredit(tx).credit).toBe(5000000 + 4992400); + expect(mempool.getCredit(new Transaction()).credit).toBe(0); }); it('marks outpoint as spent correctly', () => { diff --git a/tests/unit/reader.spec.js b/tests/unit/reader.spec.js new file mode 100644 index 000000000..ffec131c3 --- /dev/null +++ b/tests/unit/reader.spec.js @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { Reader } from '../../scripts/reader.js'; + +function createMockStream(chunks, contentLength = null) { + let i = 0; + return { + headers: { + get: (key) => (key === 'Content-Length' ? contentLength : null), + }, + body: { + getReader: () => { + return { + read: async () => { + if (i < chunks.length) { + const value = chunks[i]; + i++; + return { done: false, value }; + } + return { done: true, value: null }; + }, + }; + }, + }, + }; +} + +describe('Reader without content length', () => { + it('should read bytes correctly when available', async () => { + const mockStream = createMockStream([ + new Uint8Array([1, 2, 3, 4]), + new Uint8Array([5, 6, 7, 8]), + ]); + + const reader = new Reader(mockStream); + + const result1 = await reader.read(4); + expect(result1).toEqual(new Uint8Array([1, 2, 3, 4])); + + const result2 = await reader.read(4); + expect(result2).toEqual(new Uint8Array([5, 6, 7, 8])); + + // Reads after the stream is done should yield null + expect(await reader.read(10)).toBe(null); + }); + + it('should wait for more bytes if not enough are available', async () => { + const mockStream = createMockStream([ + new Uint8Array([1, 2, 3]), + new Uint8Array([4, 5, 6]), + ]); + + const reader = new Reader(mockStream); + + const result = await reader.read(6); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6])); + // Reads after the stream is done should yield null + expect(await reader.read(1)).toBe(null); + }); + + it('should throw an error if read is called multiple times concurrently', async () => { + const mockStream = createMockStream([new Uint8Array([1, 2, 3])]); + + const reader = new Reader(mockStream); + + const read1 = reader.read(2); + const read2 = reader.read(2); + + await expect(read2).rejects.toThrow('Called read more than once'); + await expect(read1).resolves.toEqual(new Uint8Array([1, 2])); + }); + + it('should handle reading less than available bytes', async () => { + const mockStream = createMockStream([new Uint8Array([1, 2, 3, 4, 5])]); + + const reader = new Reader(mockStream); + + const result1 = await reader.read(3); + expect(result1).toEqual(new Uint8Array([1, 2, 3])); + + const result2 = await reader.read(2); + expect(result2).toEqual(new Uint8Array([4, 5])); + }); +}); + +describe('Reader with Content-Length', () => { + it('should initialize buffer size based on Content-Length header', async () => { + const mockStream = createMockStream([], 2048); + const reader = new Reader(mockStream); + + // Read some bytes to indirectly validate initialization + const readPromise = reader.read(0); // No bytes to read, but ensures no errors + await expect(readPromise).resolves.toEqual(new Uint8Array(0)); + }); + + it('should work if Content-Length is not set', async () => { + const mockStream = createMockStream([]); + const reader = new Reader(mockStream); + + // Read some bytes to validate no Content-Length doesn't break initialization + const readPromise = reader.read(0); + await expect(readPromise).resolves.toEqual(new Uint8Array(0)); + }); + + it('should handle reading bytes when Content-Length is specified', async () => { + const mockStream = createMockStream( + [new Uint8Array([1, 2, 3, 4])], + 2048 // Content-Length + ); + + const reader = new Reader(mockStream); + + const result = await reader.read(4); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it('should resize the buffer if more bytes are received than Content-Length', async () => { + const mockStream = createMockStream( + [new Uint8Array([1, 2, 3, 4]), new Uint8Array([5, 6, 7, 8])], + 4 // Content-Length is smaller than total bytes received + ); + + const reader = new Reader(mockStream); + + const result1 = await reader.read(4); + expect(result1).toEqual(new Uint8Array([1, 2, 3, 4])); + + const result2 = await reader.read(4); + expect(result2).toEqual(new Uint8Array([5, 6, 7, 8])); + }); +});