diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b392f2dc2..8a29e9184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: Install ${{ steps.gettoolchain.outputs.toolchain }} Rust toolchain # Latest version available on this commit is 1.71.1 # Commit date is Aug 3, 2023 - uses: dtolnay/rust-toolchain@bb45937a053e097f8591208d8e74c90db1873d07 + uses: dtolnay/rust-toolchain@d8352f6b1d2e870bc5716e7a6d9b65c4cc244a1a with: toolchain: ${{ steps.gettoolchain.outputs.toolchain }} @@ -257,7 +257,7 @@ jobs: spec: | cypress/e2e/mainnet/*.spec.ts cypress/e2e/signet/*.spec.ts - cypress/e2e/testnet/*.spec.ts + cypress/e2e/testnet4/*.spec.ts - module: "liquid" spec: | cypress/e2e/liquid/liquid.spec.ts diff --git a/backend/.eslintrc b/backend/.eslintrc index 1b2889e50..c0f25066b 100644 --- a/backend/.eslintrc +++ b/backend/.eslintrc @@ -20,6 +20,7 @@ "@typescript-eslint/no-this-alias": 1, "@typescript-eslint/no-var-requires": 1, "@typescript-eslint/explicit-function-return-type": 1, + "@typescript-eslint/no-unused-vars": 1, "no-console": 1, "no-constant-condition": 1, "no-dupe-else-if": 1, @@ -32,6 +33,7 @@ "prefer-rest-params": 1, "quotes": [1, "single", { "allowTemplateLiterals": true }], "semi": 1, + "curly": [1, "all"], "eqeqeq": 1 } } diff --git a/backend/package-lock.json b/backend/package-lock.json index e42474913..0576e27df 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,7 +23,7 @@ "rust-gbt": "file:./rust-gbt", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", - "ws": "~8.16.0" + "ws": "~8.17.0" }, "devDependencies": { "@babel/code-frame": "^7.18.6", @@ -7690,9 +7690,9 @@ } }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "engines": { "node": ">=10.0.0" }, @@ -13424,9 +13424,9 @@ } }, "ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "requires": {} }, "y18n": { diff --git a/backend/package.json b/backend/package.json index affd89ead..a330fa709 100644 --- a/backend/package.json +++ b/backend/package.json @@ -52,7 +52,7 @@ "redis": "^4.6.6", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", - "ws": "~8.16.0" + "ws": "~8.17.0" }, "devDependencies": { "@babel/code-frame": "^7.18.6", diff --git a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts index cc0c801b5..4ec50f4b3 100644 --- a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts +++ b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -1,4 +1,4 @@ -import { IBitcoinApi } from './bitcoin-api.interface'; +import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IEsploraApi } from './esplora-api.interface'; export interface AbstractBitcoinApi { @@ -22,6 +22,7 @@ export interface AbstractBitcoinApi { $getScriptHash(scripthash: string): Promise; $getScriptHashTransactions(address: string, lastSeenTxId: string): Promise; $sendRawTransaction(rawTransaction: string): Promise; + $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise; $getOutspend(txId: string, vout: number): Promise; $getOutspends(txId: string): Promise; $getBatchedOutspends(txId: string[]): Promise; diff --git a/backend/src/api/bitcoin/bitcoin-api.interface.ts b/backend/src/api/bitcoin/bitcoin-api.interface.ts index e176566d7..6e8583f6f 100644 --- a/backend/src/api/bitcoin/bitcoin-api.interface.ts +++ b/backend/src/api/bitcoin/bitcoin-api.interface.ts @@ -205,3 +205,16 @@ export namespace IBitcoinApi { "utxo_size_inc": number; } } + +export interface TestMempoolAcceptResult { + txid: string, + wtxid: string, + allowed?: boolean, + vsize?: number, + fees?: { + base: number, + "effective-feerate": number, + "effective-includes": string[], + }, + ['reject-reason']?: string, +} diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index d19eb06ac..c3304b432 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -1,6 +1,6 @@ import * as bitcoinjs from 'bitcoinjs-lib'; import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-factory'; -import { IBitcoinApi } from './bitcoin-api.interface'; +import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { IEsploraApi } from './esplora-api.interface'; import blocks from '../blocks'; import mempool from '../mempool'; @@ -174,6 +174,14 @@ class BitcoinApi implements AbstractBitcoinApi { return this.bitcoindClient.sendRawTransaction(rawTransaction); } + async $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise { + if (rawTransactions.length) { + return this.bitcoindClient.testMempoolAccept(rawTransactions, maxfeerate ?? undefined); + } else { + return []; + } + } + async $getOutspend(txId: string, vout: number): Promise { const txOut = await this.bitcoindClient.getTxOut(txId, vout, false); return { diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index a82cda3f0..ec63f4ff8 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -55,6 +55,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'mempool/recent', this.getRecentMempoolTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId', this.getTransaction) .post(config.MEMPOOL.API_URL_PREFIX + 'tx', this.$postTransaction) + .post(config.MEMPOOL.API_URL_PREFIX + 'txs/test', this.$testTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/hex', this.getRawTransaction) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends) @@ -749,6 +750,19 @@ class BitcoinRoutes { } } + private async $testTransactions(req: Request, res: Response) { + try { + const rawTxs = Common.getTransactionsFromRequest(req); + const maxfeerate = parseFloat(req.query.maxfeerate as string); + const result = await bitcoinApi.$testMempoolAccept(rawTxs, maxfeerate); + res.send(result); + } catch (e: any) { + res.setHeader('content-type', 'text/plain'); + res.status(400).send(e.message && e.code ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) + : (e.message || 'Error')); + } + } + } export default new BitcoinRoutes(); diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index a9dadf4a0..90e50d4c2 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -5,6 +5,7 @@ import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-fact import { IEsploraApi } from './esplora-api.interface'; import logger from '../../logger'; import { Common } from '../common'; +import { TestMempoolAcceptResult } from './bitcoin-api.interface'; interface FailoverHost { host: string, @@ -327,6 +328,10 @@ class ElectrsApi implements AbstractBitcoinApi { throw new Error('Method not implemented.'); } + $testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise { + throw new Error('Method not implemented.'); + } + $getOutspend(txId: string, vout: number): Promise { return this.failoverRouter.$get('/tx/' + txId + '/outspend/' + vout); } diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 16ae94f66..42e15bf82 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -839,8 +839,11 @@ class Blocks { } else { this.currentBlockHeight++; logger.debug(`New block found (#${this.currentBlockHeight})!`); - this.updateTimerProgress(timer, `getting orphaned blocks for ${this.currentBlockHeight}`); - await chainTips.updateOrphanedBlocks(); + // skip updating the orphan block cache if we've fallen behind the chain tip + if (this.currentBlockHeight >= blockHeightTip - 2) { + this.updateTimerProgress(timer, `getting orphaned blocks for ${this.currentBlockHeight}`); + await chainTips.updateOrphanedBlocks(); + } } this.updateTimerProgress(timer, `getting block data for ${this.currentBlockHeight}`); diff --git a/backend/src/api/chain-tips.ts b/backend/src/api/chain-tips.ts index b68b0b281..b7fd05ad8 100644 --- a/backend/src/api/chain-tips.ts +++ b/backend/src/api/chain-tips.ts @@ -12,32 +12,68 @@ export interface OrphanedBlock { height: number; hash: string; status: 'valid-fork' | 'valid-headers' | 'headers-only'; + prevhash: string; } class ChainTips { private chainTips: ChainTip[] = []; - private orphanedBlocks: OrphanedBlock[] = []; + private orphanedBlocks: { [hash: string]: OrphanedBlock } = {}; + private blockCache: { [hash: string]: OrphanedBlock } = {}; + private orphansByHeight: { [height: number]: OrphanedBlock[] } = {}; public async updateOrphanedBlocks(): Promise { try { this.chainTips = await bitcoinClient.getChainTips(); - this.orphanedBlocks = []; + + const start = Date.now(); + const breakAt = start + 10000; + let newOrphans = 0; + this.orphanedBlocks = {}; for (const chain of this.chainTips) { if (chain.status === 'valid-fork' || chain.status === 'valid-headers') { - let block = await bitcoinClient.getBlock(chain.hash); - while (block && block.confirmations === -1) { - this.orphanedBlocks.push({ - height: block.height, - hash: block.hash, - status: chain.status - }); - block = await bitcoinClient.getBlock(block.previousblockhash); + const orphans: OrphanedBlock[] = []; + let hash = chain.hash; + do { + let orphan = this.blockCache[hash]; + if (!orphan) { + const block = await bitcoinClient.getBlock(hash); + if (block && block.confirmations === -1) { + newOrphans++; + orphan = { + height: block.height, + hash: block.hash, + status: chain.status, + prevhash: block.previousblockhash, + }; + this.blockCache[hash] = orphan; + } + } + if (orphan) { + orphans.push(orphan); + } + hash = orphan?.prevhash; + } while (hash && (Date.now() < breakAt)); + for (const orphan of orphans) { + this.orphanedBlocks[orphan.hash] = orphan; } } + if (Date.now() >= breakAt) { + logger.debug(`Breaking orphaned blocks updater after 10s, will continue next block`); + break; + } } - logger.debug(`Updated orphaned blocks cache. Found ${this.orphanedBlocks.length} orphaned blocks`); + this.orphansByHeight = {}; + const allOrphans = Object.values(this.orphanedBlocks); + for (const orphan of allOrphans) { + if (!this.orphansByHeight[orphan.height]) { + this.orphansByHeight[orphan.height] = []; + } + this.orphansByHeight[orphan.height].push(orphan); + } + + logger.debug(`Updated orphaned blocks cache. Fetched ${newOrphans} new orphaned blocks. Total ${allOrphans.length}`); } catch (e) { logger.err(`Cannot get fetch orphaned blocks. Reason: ${e instanceof Error ? e.message : e}`); } @@ -48,13 +84,7 @@ class ChainTips { return []; } - const orphans: OrphanedBlock[] = []; - for (const block of this.orphanedBlocks) { - if (block.height === height) { - orphans.push(block); - } - } - return orphans; + return this.orphansByHeight[height] || []; } } diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 92dfceb52..2c9338814 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -946,6 +946,33 @@ export class Common { return this.validateTransactionHex(matches[1].toLowerCase()); } + static getTransactionsFromRequest(req: Request, limit: number = 25): string[] { + if (!Array.isArray(req.body) || req.body.some(hex => typeof hex !== 'string')) { + throw Object.assign(new Error('Invalid request body (should be an array of hexadecimal strings)'), { code: -1 }); + } + + if (limit && req.body.length > limit) { + throw Object.assign(new Error('Exceeded maximum of 25 transactions'), { code: -1 }); + } + + const txs = req.body; + + return txs.map(rawTx => { + // Support both upper and lower case hex + // Support both txHash= Form and direct API POST + const reg = /^((?:[a-fA-F0-9]{2})+)$/; + const matches = reg.exec(rawTx); + if (!matches || !matches[1]) { + throw Object.assign(new Error('Invalid hex string'), { code: -2 }); + } + + // Guaranteed to be a hex string of multiple of 2 + // Guaranteed to be lower case + // Guaranteed to pass validation (see function below) + return this.validateTransactionHex(matches[1].toLowerCase()); + }); + } + private static validateTransactionHex(txhex: string): string { // Do not mutate txhex diff --git a/backend/src/api/explorer/channels.routes.ts b/backend/src/api/explorer/channels.routes.ts index f28ab2a9d..391bf628e 100644 --- a/backend/src/api/explorer/channels.routes.ts +++ b/backend/src/api/explorer/channels.routes.ts @@ -54,9 +54,11 @@ class ChannelsRoutes { if (index < -1) { res.status(400).send('Invalid index'); + return; } if (['open', 'active', 'closed'].includes(status) === false) { res.status(400).send('Invalid status'); + return; } const channels = await channelsApi.$getChannelsForNode(req.query.public_key, index, 10, status); diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 3c65ee1f8..22c854fcc 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -666,7 +666,9 @@ class NodesApi { node.last_update = null; } - const sockets = (node.addresses?.map(a => a.addr).join(',')) ?? ''; + const uniqueAddr = [...new Set(node.addresses?.map(a => a.addr))]; + const formattedSockets = (uniqueAddr.join(',')) ?? ''; + const query = `INSERT INTO nodes( public_key, first_seen, @@ -695,13 +697,13 @@ class NodesApi { node.alias, this.aliasToSearchText(node.alias), node.color, - sockets, + formattedSockets, JSON.stringify(node.features), node.last_update, node.alias, this.aliasToSearchText(node.alias), node.color, - sockets, + formattedSockets, JSON.stringify(node.features), ]); } catch (e) { diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 176bedddb..c93d51005 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -404,6 +404,10 @@ class Mempool { const newAccelerationMap: { [txid: string]: Acceleration } = {}; for (const acceleration of newAccelerations) { + // skip transactions we don't know about + if (!this.mempoolCache[acceleration.txid]) { + continue; + } newAccelerationMap[acceleration.txid] = acceleration; if (this.accelerations[acceleration.txid] == null) { // new acceleration diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index 3492114b5..b6ce3ba70 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -24,6 +24,7 @@ class MiningRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments', this.$getDifficultyAdjustments) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/reward-stats/:blockCount', this.$getRewardStats) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees/:interval', this.$getHistoricalBlockFees) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees', this.$getBlockFeesTimespan) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/rewards/:interval', this.$getHistoricalBlockRewards) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fee-rates/:interval', this.$getHistoricalBlockFeeRates) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight) @@ -217,6 +218,26 @@ class MiningRoutes { } } + private async $getBlockFeesTimespan(req: Request, res: Response) { + try { + if (!parseInt(req.query.from as string, 10) || !parseInt(req.query.to as string, 10)) { + throw new Error('Invalid timestamp range'); + } + if (parseInt(req.query.from as string, 10) > parseInt(req.query.to as string, 10)) { + throw new Error('from must be less than to'); + } + const blockFees = await mining.$getBlockFeesTimespan(parseInt(req.query.from as string, 10), parseInt(req.query.to as string, 10)); + const blockCount = await BlocksRepository.$blockCount(null, null); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.header('X-total-count', blockCount.toString()); + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + res.json(blockFees); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async $getHistoricalBlockRewards(req: Request, res: Response) { try { const blockRewards = await mining.$getHistoricalBlockRewards(req.params.interval); diff --git a/backend/src/api/mining/mining.ts b/backend/src/api/mining/mining.ts index 85554db2d..21ee4b35a 100644 --- a/backend/src/api/mining/mining.ts +++ b/backend/src/api/mining/mining.ts @@ -45,11 +45,22 @@ class Mining { */ public async $getHistoricalBlockFees(interval: string | null = null): Promise { return await BlocksRepository.$getHistoricalBlockFees( - this.getTimeRange(interval, 5), + this.getTimeRange(interval), Common.getSqlInterval(interval) ); } + /** + * Get timespan block total fees + */ + public async $getBlockFeesTimespan(from: number, to: number): Promise { + return await BlocksRepository.$getHistoricalBlockFees( + this.getTimeRangeFromTimespan(from, to), + null, + {from, to} + ); + } + /** * Get historical block rewards */ @@ -646,6 +657,24 @@ class Mining { } } + private getTimeRangeFromTimespan(from: number, to: number, scale = 1): number { + const timespan = to - from; + switch (true) { + case timespan > 3600 * 24 * 365 * 4: return 86400 * scale; // 24h + case timespan > 3600 * 24 * 365 * 3: return 43200 * scale; // 12h + case timespan > 3600 * 24 * 365 * 2: return 43200 * scale; // 12h + case timespan > 3600 * 24 * 365: return 28800 * scale; // 8h + case timespan > 3600 * 24 * 30 * 6: return 28800 * scale; // 8h + case timespan > 3600 * 24 * 30 * 3: return 10800 * scale; // 3h + case timespan > 3600 * 24 * 30: return 7200 * scale; // 2h + case timespan > 3600 * 24 * 7: return 1800 * scale; // 30min + case timespan > 3600 * 24 * 3: return 300 * scale; // 5min + case timespan > 3600 * 24: return 1 * scale; + default: return 1 * scale; + } + } + + // Finds the oldest block in a consecutive chain back from the tip // assumes `blocks` is sorted in ascending height order private getOldestConsecutiveBlock(blocks: DifficultyBlock[]): DifficultyBlock { diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index d4ff7efe3..f92e6cdfe 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -3,6 +3,7 @@ import * as WebSocket from 'ws'; import { BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse, OptimizedStatistic, ILoadingIndicators, GbtCandidates, TxTrackingInfo, + MempoolBlockDelta, MempoolDelta, MempoolDeltaTxids } from '../mempool.interfaces'; import blocks from './blocks'; import memPool from './mempool'; @@ -346,6 +347,17 @@ class WebsocketHandler { } } + if (parsedMessage && parsedMessage['track-accelerations'] != null) { + if (parsedMessage['track-accelerations']) { + client['track-accelerations'] = true; + response['accelerations'] = JSON.stringify({ + accelerations: Object.values(memPool.getAccelerations()), + }); + } else { + client['track-accelerations'] = false; + } + } + if (parsedMessage.action === 'init') { if (!this.socketData['blocks']?.length || !this.socketData['da'] || !this.socketData['backendInfo'] || !this.socketData['conversions']) { this.updateSocketData(); @@ -364,6 +376,18 @@ class WebsocketHandler { client['track-donation'] = parsedMessage['track-donation']; } + if (parsedMessage['track-mempool-txids'] === true) { + client['track-mempool-txids'] = true; + } else if (parsedMessage['track-mempool-txids'] === false) { + delete client['track-mempool-txids']; + } + + if (parsedMessage['track-mempool'] === true) { + client['track-mempool'] = true; + } else if (parsedMessage['track-mempool'] === false) { + delete client['track-mempool']; + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } @@ -524,6 +548,7 @@ class WebsocketHandler { const vBytesPerSecond = memPool.getVBytesPerSecond(); const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); const da = difficultyAdjustment.getDifficultyAdjustment(); + const accelerations = memPool.getAccelerations(); memPool.handleRbfTransactions(rbfTransactions); const rbfChanges = rbfCache.getRbfChanges(); let rbfReplacements; @@ -545,6 +570,33 @@ class WebsocketHandler { const latestTransactions = memPool.getLatestTransactions(); + if (memPool.isInSync()) { + this.mempoolSequence++; + } + + const replacedTransactions: { replaced: string, by: TransactionExtended }[] = []; + for (const tx of newTransactions) { + if (rbfTransactions[tx.txid]) { + for (const replaced of rbfTransactions[tx.txid]) { + replacedTransactions.push({ replaced: replaced.txid, by: tx }); + } + } + } + const mempoolDeltaTxids: MempoolDeltaTxids = { + sequence: this.mempoolSequence, + added: newTransactions.map(tx => tx.txid), + removed: deletedTransactions.map(tx => tx.txid), + mined: [], + replaced: replacedTransactions.map(replacement => ({ replaced: replacement.replaced, by: replacement.by.txid })), + }; + const mempoolDelta: MempoolDelta = { + sequence: this.mempoolSequence, + added: newTransactions, + removed: deletedTransactions.map(tx => tx.txid), + mined: [], + replaced: replacedTransactions, + }; + // update init data const socketDataFields = { 'mempoolInfo': mempoolInfo, @@ -604,9 +656,11 @@ class WebsocketHandler { const addressCache = this.makeAddressCache(newTransactions); const removedAddressCache = this.makeAddressCache(deletedTransactions); - if (memPool.isInSync()) { - this.mempoolSequence++; - } + // pre-compute acceleration delta + const accelerationUpdate = { + added: accelerationDelta.map(txid => accelerations[txid]).filter(acc => acc != null), + removed: accelerationDelta.filter(txid => !accelerations[txid]), + }; // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { @@ -847,6 +901,18 @@ class WebsocketHandler { response['rbfLatestSummary'] = getCachedResponse('rbfLatestSummary', rbfSummary); } + if (client['track-mempool-txids']) { + response['mempool-txids'] = getCachedResponse('mempool-txids', mempoolDeltaTxids); + } + + if (client['track-mempool']) { + response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta); + } + + if (client['track-accelerations'] && (accelerationUpdate.added.length || accelerationUpdate.removed.length)) { + response['accelerations'] = getCachedResponse('accelerations', accelerationUpdate); + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } @@ -992,6 +1058,31 @@ class WebsocketHandler { const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); + if (memPool.isInSync()) { + this.mempoolSequence++; + } + + const replacedTransactions: { replaced: string, by: TransactionExtended }[] = []; + for (const txid of Object.keys(rbfTransactions)) { + for (const replaced of rbfTransactions[txid].replaced) { + replacedTransactions.push({ replaced: replaced.txid, by: rbfTransactions[txid].replacedBy }); + } + } + const mempoolDeltaTxids: MempoolDeltaTxids = { + sequence: this.mempoolSequence, + added: [], + removed: [], + mined: transactions.map(tx => tx.txid), + replaced: replacedTransactions.map(replacement => ({ replaced: replacement.replaced, by: replacement.by.txid })), + }; + const mempoolDelta: MempoolDelta = { + sequence: this.mempoolSequence, + added: [], + removed: [], + mined: transactions.map(tx => tx.txid), + replaced: replacedTransactions, + }; + const responseCache = { ...this.socketData }; function getCachedResponse(key, data): string { if (!responseCache[key]) { @@ -1000,10 +1091,6 @@ class WebsocketHandler { return responseCache[key]; } - if (memPool.isInSync()) { - this.mempoolSequence++; - } - // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach((client) => { @@ -1185,6 +1272,14 @@ class WebsocketHandler { } } + if (client['track-mempool-txids']) { + response['mempool-txids'] = getCachedResponse('mempool-txids', mempoolDeltaTxids); + } + + if (client['track-mempool']) { + response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta); + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } diff --git a/backend/src/index.ts b/backend/src/index.ts index b088155ea..df9f7dc65 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -131,6 +131,7 @@ class Server { }) .use(express.urlencoded({ extended: true })) .use(express.text({ type: ['text/plain', 'application/base64'] })) + .use(express.json()) ; if (config.DATABASE.ENABLED && config.FIAT_PRICE.ENABLED) { diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 0b4b20e02..0fcddc45a 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -71,6 +71,22 @@ export interface MempoolBlockDelta { changed: MempoolDeltaChange[]; } +export interface MempoolDeltaTxids { + sequence: number, + added: string[]; + removed: string[]; + mined: string[]; + replaced: { replaced: string, by: string }[]; +} + +export interface MempoolDelta { + sequence: number, + added: MempoolTransactionExtended[]; + removed: string[]; + mined: string[]; + replaced: { replaced: string, by: TransactionExtended }[]; +} + interface VinStrippedToScriptsig { scriptsig: string; } diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index e6e92d60f..0077a0b79 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -663,7 +663,7 @@ class BlocksRepository { /** * Get the historical averaged block fees */ - public async $getHistoricalBlockFees(div: number, interval: string | null): Promise { + public async $getHistoricalBlockFees(div: number, interval: string | null, timespan?: {from: number, to: number}): Promise { try { let query = `SELECT CAST(AVG(blocks.height) as INT) as avgHeight, @@ -677,6 +677,8 @@ class BlocksRepository { if (interval !== null) { query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } else if (timespan) { + query += ` WHERE blockTimestamp BETWEEN FROM_UNIXTIME(${timespan.from}) AND FROM_UNIXTIME(${timespan.to})`; } query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`; diff --git a/contributors/bitcoinmechanic.txt b/contributors/bitcoinmechanic.txt new file mode 100644 index 000000000..b2574b2fa --- /dev/null +++ b/contributors/bitcoinmechanic.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022. + +Signed: bitcoinmechanic diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index d8eada208..4bd2e8c30 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.12.0-buster-slim AS builder +FROM node:20.13.1-buster-slim AS builder ARG commitHash ENV MEMPOOL_COMMIT_HASH=${commitHash} @@ -24,7 +24,7 @@ RUN npm install --omit=dev --omit=optional WORKDIR /build RUN npm run package -FROM node:20.12.0-buster-slim +FROM node:20.13.1-buster-slim WORKDIR /backend diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 3a63107bf..211ca8595 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.12.0-buster-slim AS builder +FROM node:20.13.1-buster-slim AS builder ARG commitHash ENV DOCKER_COMMIT_HASH=${commitHash} @@ -13,7 +13,7 @@ RUN npm install --omit=dev --omit=optional RUN npm run build -FROM nginx:1.25.4-alpine +FROM nginx:1.26.0-alpine WORKDIR /patch diff --git a/frontend/.eslintrc b/frontend/.eslintrc index e2652c6c8..d47d4fe9b 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -34,6 +34,7 @@ "prefer-rest-params": 1, "quotes": [1, "single", { "allowTemplateLiterals": true }], "semi": 1, + "curly": [1, "all"], "eqeqeq": 1 } } diff --git a/frontend/.gitignore b/frontend/.gitignore index d2a765dda..c10a00946 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -63,6 +63,7 @@ src/resources/pools.json src/resources/mining-pools/* src/resources/**/*.mp4 src/resources/**/*.vtt +src/resources/customize.js # environment config mempool-frontend-config.json diff --git a/frontend/angular.json b/frontend/angular.json index f55c09934..190982225 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -166,6 +166,7 @@ "src/resources", "src/robots.txt", "src/config.js", + "src/customize.js", "src/config.template.js" ], "styles": [ @@ -180,6 +181,11 @@ "bundleName": "wiz", "inject": false }, + { + "input": "src/theme-bukele.scss", + "bundleName": "bukele", + "inject": false + }, "node_modules/@fortawesome/fontawesome-svg-core/styles.css" ], "vendorChunk": true, diff --git a/frontend/custom-sv-config.json b/frontend/custom-sv-config.json new file mode 100644 index 000000000..dee3dab18 --- /dev/null +++ b/frontend/custom-sv-config.json @@ -0,0 +1,52 @@ +{ + "theme": "bukele", + "enterprise": "onbtc", + "branding": { + "name": "onbtc", + "title": "Bitcoin Office", + "site_id": 19, + "header_img": "/resources/onbtclogo.svg", + "footer_img": "/resources/onbtclogo.svg", + "rounded_corner": true + }, + "dashboard": { + "widgets": [ + { + "component": "fees", + "mobileOrder": 4 + }, + { + "component": "balance", + "mobileOrder": 1, + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + } + }, + { + "component": "twitter", + "mobileOrder": 5, + "props": { + "handle": "nayibbukele" + } + }, + { + "component": "address", + "mobileOrder": 2, + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo", + "period": "1m" + } + }, + { + "component": "blocks" + }, + { + "component": "addressTransactions", + "mobileOrder": 3, + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + } + } + ] + } +} \ No newline at end of file diff --git a/frontend/cypress/e2e/mainnet/mainnet.spec.ts b/frontend/cypress/e2e/mainnet/mainnet.spec.ts index c0f5cbfda..5032144f8 100644 --- a/frontend/cypress/e2e/mainnet/mainnet.spec.ts +++ b/frontend/cypress/e2e/mainnet/mainnet.spec.ts @@ -112,8 +112,8 @@ describe('Mainnet', () => { it('check op_return coinbase tooltip', () => { cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2'); cy.waitForSkeletonGone(); - cy.get('div > a > .badge').first().trigger('onmouseover'); - cy.get('div > a > .badge').first().trigger('mouseenter'); + cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover'); + cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter'); cy.get('.tooltip-inner').should('be.visible'); }); @@ -339,7 +339,7 @@ describe('Mainnet', () => { cy.visit('/'); cy.waitForSkeletonGone(); - cy.changeNetwork('testnet'); + cy.changeNetwork('testnet4'); cy.changeNetwork('signet'); cy.changeNetwork('mainnet'); }); diff --git a/frontend/cypress/e2e/testnet/testnet.spec.ts b/frontend/cypress/e2e/testnet4/testnet4.spec.ts similarity index 86% rename from frontend/cypress/e2e/testnet/testnet.spec.ts rename to frontend/cypress/e2e/testnet4/testnet4.spec.ts index 4236ca207..4e2b6e3fa 100644 --- a/frontend/cypress/e2e/testnet/testnet.spec.ts +++ b/frontend/cypress/e2e/testnet4/testnet4.spec.ts @@ -2,7 +2,7 @@ import { emitMempoolInfo } from '../../support/websocket'; const baseModule = Cypress.env('BASE_MODULE'); -describe('Testnet', () => { +describe('Testnet4', () => { beforeEach(() => { cy.intercept('/api/block-height/*').as('block-height'); cy.intercept('/api/block/*').as('block'); @@ -13,7 +13,7 @@ describe('Testnet', () => { if (baseModule === 'mempool') { it('loads the dashboard', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); }); @@ -25,7 +25,7 @@ describe('Testnet', () => { it.skip('loads the dashboard with the skeleton blocks', () => { cy.mockMempoolSocket(); - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible'); cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible'); cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible'); @@ -45,7 +45,7 @@ describe('Testnet', () => { }); it('loads the pools screen', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); cy.get('#btn-pools').click().then(() => { cy.wait(1000); @@ -53,7 +53,7 @@ describe('Testnet', () => { }); it('loads the graphs screen', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); cy.get('#btn-graphs').click().then(() => { cy.wait(1000); @@ -63,7 +63,7 @@ describe('Testnet', () => { describe('tv mode', () => { it('loads the tv screen - desktop', () => { cy.viewport('macbook-16'); - cy.visit('/testnet/graphs'); + cy.visit('/testnet4/graphs'); cy.waitForSkeletonGone(); cy.get('#btn-tv').click().then(() => { cy.wait(1000); @@ -73,7 +73,7 @@ describe('Testnet', () => { }); it('loads the tv screen - mobile', () => { - cy.visit('/testnet/graphs'); + cy.visit('/testnet4/graphs'); cy.waitForSkeletonGone(); cy.get('#btn-tv').click().then(() => { cy.viewport('iphone-6'); @@ -85,7 +85,7 @@ describe('Testnet', () => { it('loads the api screen', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); cy.get('#btn-docs').click().then(() => { cy.wait(1000); @@ -94,13 +94,13 @@ describe('Testnet', () => { describe('blocks', () => { it('shows empty blocks properly', () => { - cy.visit('/testnet/block/0'); + cy.visit('/testnet4/block/0'); cy.waitForSkeletonGone(); cy.get('h2').invoke('text').should('equal', '1 transaction'); }); it('expands and collapses the block details', () => { - cy.visit('/testnet/block/0'); + cy.visit('/testnet4/block/0'); cy.waitForSkeletonGone(); cy.get('.btn.btn-outline-info').click().then(() => { cy.get('#details').should('be.visible'); @@ -112,15 +112,15 @@ describe('Testnet', () => { }); it('shows blocks with no pagination', () => { - cy.visit('/testnet/block/000000000000002f8ce27716e74ecc7ad9f7b5101fed12d09e28bb721b9460ea'); + cy.visit('/testnet4/block/000000000066e8b6cc78a93f8989587f5819624bae2eb1c05f535cadded19f99'); cy.waitForSkeletonGone(); - cy.get('h2').invoke('text').should('equal', '11 transactions'); + cy.get('h2').invoke('text').should('equal', '18 transactions'); cy.get('ul.pagination').first().children().should('have.length', 5); }); it('supports pagination on the block screen', () => { // 48 txs - cy.visit('/testnet/block/000000000000002ca3878ebd98b313a1c2d531f2e70a6575d232ca7564dea7a9'); + cy.visit('/testnet4/block/000000000000006982d53f8273bdff21dafc380c292eabc669b5ab6d732311c3'); cy.waitForSkeletonGone(); cy.get('.header-bg.box > a').invoke('text').then((text1) => { cy.get('.active + li').first().click().then(() => { diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 23376e5b2..018f63569 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -72,7 +72,7 @@ Cypress.Commands.add('mockMempoolSocket', () => { mockWebSocket(); }); -Cypress.Commands.add('changeNetwork', (network: "testnet" | "signet" | "liquid" | "mainnet") => { +Cypress.Commands.add('changeNetwork', (network: "testnet" | "testnet4" | "signet" | "liquid" | "mainnet") => { cy.get('.dropdown-toggle').click().then(() => { cy.get(`a.${network}`).click().then(() => { cy.waitForPageIdle(); diff --git a/frontend/cypress/support/index.d.ts b/frontend/cypress/support/index.d.ts index 3cc151b21..2c5328301 100644 --- a/frontend/cypress/support/index.d.ts +++ b/frontend/cypress/support/index.d.ts @@ -5,6 +5,6 @@ declare namespace Cypress { waitForSkeletonGone(): Chainable waitForPageIdle(): Chainable mockMempoolSocket(): Chainable - changeNetwork(network: "testnet"|"signet"|"liquid"|"mainnet"): Chainable + changeNetwork(network: "testnet"|"testnet4"|"signet"|"liquid"|"mainnet"): Chainable } } \ No newline at end of file diff --git a/frontend/generate-config.js b/frontend/generate-config.js index c7a81a482..89d7143fd 100644 --- a/frontend/generate-config.js +++ b/frontend/generate-config.js @@ -4,11 +4,14 @@ const { spawnSync } = require('child_process'); const CONFIG_FILE_NAME = 'mempool-frontend-config.json'; const GENERATED_CONFIG_FILE_NAME = 'src/resources/config.js'; const GENERATED_TEMPLATE_CONFIG_FILE_NAME = 'src/resources/config.template.js'; +const GENERATED_CUSTOMIZATION_FILE_NAME = 'src/resources/customize.js'; let settings = []; let configContent = {}; let gitCommitHash = ''; let packetJsonVersion = ''; +let customConfig; +let customConfigContent; try { const rawConfig = fs.readFileSync(CONFIG_FILE_NAME); @@ -22,7 +25,18 @@ try { } } -const indexFilePath = configContent.BASE_MODULE ? 'src/index.' + configContent.BASE_MODULE + '.html' : 'src/index.mempool.html'; +if (configContent && configContent.CUSTOMIZATION) { + try { + customConfig = readConfig(configContent.CUSTOMIZATION); + customConfigContent = JSON.parse(customConfig); + } catch (e) { + console.log(`failed to load customization config from ${configContent.CUSTOMIZATION}`); + } +} + +const baseModuleName = configContent.BASE_MODULE || 'mempool'; +const customBuildName = (customConfigContent && customConfigContent.enterprise) ? ('.' + customConfigContent.enterprise) : ''; +const indexFilePath = 'src/index.' + baseModuleName + customBuildName + '.html'; try { fs.copyFileSync(indexFilePath, 'src/index.html'); @@ -109,6 +123,17 @@ writeConfigTemplate(GENERATED_TEMPLATE_CONFIG_FILE_NAME, newConfigTemplate); const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME); +let customConfigJs = ''; +if (customConfig) { + console.log(`Customizing frontend using ${configContent.CUSTOMIZATION}`); + customConfigJs = `(function (window) { + window.__env = window.__env || {}; + window.__env.customize = ${customConfig}; + }((typeof global !== 'undefined') ? global : this)); + `; +} +writeConfig(GENERATED_CUSTOMIZATION_FILE_NAME, customConfigJs); + if (currentConfig && currentConfig === newConfig) { console.log(`No configuration updates, skipping ${GENERATED_CONFIG_FILE_NAME} file update`); return; diff --git a/frontend/mempool-frontend-config.sample.json b/frontend/mempool-frontend-config.sample.json index 7f06c8fbc..c111f35af 100644 --- a/frontend/mempool-frontend-config.sample.json +++ b/frontend/mempool-frontend-config.sample.json @@ -1,5 +1,6 @@ { "TESTNET_ENABLED": false, + "TESTNET4_ENABLED": false, "SIGNET_ENABLED": false, "LIQUID_ENABLED": false, "LIQUID_TESTNET_ENABLED": false, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e0702a01..83f9234cb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,10 +32,10 @@ "bootstrap": "~4.6.2", "browserify": "^17.0.0", "clipboard": "^2.0.11", - "cypress": "^13.8.0", + "cypress": "^13.9.0", "domino": "^2.1.6", "echarts": "~5.5.0", - "esbuild": "^0.20.2", + "esbuild": "^0.21.1", "lightweight-charts": "~3.8.0", "ngx-echarts": "~17.1.0", "ngx-infinite-scroll": "^17.0.0", @@ -63,7 +63,7 @@ "optionalDependencies": { "@cypress/schematic": "^2.5.0", "@types/cypress": "^1.1.3", - "cypress": "^13.8.0", + "cypress": "^13.9.0", "cypress-fail-on-console-error": "~5.1.0", "cypress-wait-until": "^2.0.1", "mock-socket": "~9.3.1", @@ -3197,9 +3197,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.1.tgz", + "integrity": "sha512-O7yppwipkXvnEPjzkSXJRk2g4bS8sUx9p9oXHq9MU/U7lxUzZVsnFZMDTmeeX9bfQxrFcvOacl/ENgOh0WP9pA==", "cpu": [ "ppc64" ], @@ -3212,9 +3212,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.1.tgz", + "integrity": "sha512-hh3jKWikdnTtHCglDAeVO3Oyh8MaH8xZUaWMiCCvJ9/c3NtPqZq+CACOlGTxhddypXhl+8B45SeceYBfB/e8Ow==", "cpu": [ "arm" ], @@ -3227,9 +3227,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.1.tgz", + "integrity": "sha512-jXhccq6es+onw7x8MxoFnm820mz7sGa9J14kLADclmiEUH4fyj+FjR6t0M93RgtlI/awHWhtF0Wgfhqgf9gDZA==", "cpu": [ "arm64" ], @@ -3242,9 +3242,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.1.tgz", + "integrity": "sha512-NPObtlBh4jQHE01gJeucqEhdoD/4ya2owSIS8lZYS58aR0x7oZo9lB2lVFxgTANSa5MGCBeoQtr+yA9oKCGPvA==", "cpu": [ "x64" ], @@ -3257,9 +3257,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.1.tgz", + "integrity": "sha512-BLT7TDzqsVlQRmJfO/FirzKlzmDpBWwmCUlyggfzUwg1cAxVxeA4O6b1XkMInlxISdfPAOunV9zXjvh5x99Heg==", "cpu": [ "arm64" ], @@ -3272,9 +3272,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.1.tgz", + "integrity": "sha512-D3h3wBQmeS/vp93O4B+SWsXB8HvRDwMyhTNhBd8yMbh5wN/2pPWRW5o/hM3EKgk9bdKd9594lMGoTCTiglQGRQ==", "cpu": [ "x64" ], @@ -3287,9 +3287,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.1.tgz", + "integrity": "sha512-/uVdqqpNKXIxT6TyS/oSK4XE4xWOqp6fh4B5tgAwozkyWdylcX+W4YF2v6SKsL4wCQ5h1bnaSNjWPXG/2hp8AQ==", "cpu": [ "arm64" ], @@ -3302,9 +3302,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.1.tgz", + "integrity": "sha512-paAkKN1n1jJitw+dAoR27TdCzxRl1FOEITx3h201R6NoXUojpMzgMLdkXVgCvaCSCqwYkeGLoe9UVNRDKSvQgw==", "cpu": [ "x64" ], @@ -3317,9 +3317,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.1.tgz", + "integrity": "sha512-tRHnxWJnvNnDpNVnsyDhr1DIQZUfCXlHSCDohbXFqmg9W4kKR7g8LmA3kzcwbuxbRMKeit8ladnCabU5f2traA==", "cpu": [ "arm" ], @@ -3332,9 +3332,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.1.tgz", + "integrity": "sha512-G65d08YoH00TL7Xg4LaL3gLV21bpoAhQ+r31NUu013YB7KK0fyXIt05VbsJtpqh/6wWxoLJZOvQHYnodRrnbUQ==", "cpu": [ "arm64" ], @@ -3347,9 +3347,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.1.tgz", + "integrity": "sha512-tt/54LqNNAqCz++QhxoqB9+XqdsaZOtFD/srEhHYwBd3ZUOepmR1Eeot8bS+Q7BiEvy9vvKbtpHf+r6q8hF5UA==", "cpu": [ "ia32" ], @@ -3362,9 +3362,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.1.tgz", + "integrity": "sha512-MhNalK6r0nZD0q8VzUBPwheHzXPr9wronqmZrewLfP7ui9Fv1tdPmg6e7A8lmg0ziQCziSDHxh3cyRt4YMhGnQ==", "cpu": [ "loong64" ], @@ -3377,9 +3377,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.1.tgz", + "integrity": "sha512-YCKVY7Zen5rwZV+nZczOhFmHaeIxR4Zn3jcmNH53LbgF6IKRwmrMywqDrg4SiSNApEefkAbPSIzN39FC8VsxPg==", "cpu": [ "mips64el" ], @@ -3392,9 +3392,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.1.tgz", + "integrity": "sha512-bw7bcQ+270IOzDV4mcsKAnDtAFqKO0jVv3IgRSd8iM0ac3L8amvCrujRVt1ajBTJcpDaFhIX+lCNRKteoDSLig==", "cpu": [ "ppc64" ], @@ -3407,9 +3407,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.1.tgz", + "integrity": "sha512-ARmDRNkcOGOm1AqUBSwRVDfDeD9hGYRfkudP2QdoonBz1ucWVnfBPfy7H4JPI14eYtZruRSczJxyu7SRYDVOcg==", "cpu": [ "riscv64" ], @@ -3422,9 +3422,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.1.tgz", + "integrity": "sha512-o73TcUNMuoTZlhwFdsgr8SfQtmMV58sbgq6gQq9G1xUiYnHMTmJbwq65RzMx89l0iya69lR4bxBgtWiiOyDQZA==", "cpu": [ "s390x" ], @@ -3437,9 +3437,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.1.tgz", + "integrity": "sha512-da4/1mBJwwgJkbj4fMH7SOXq2zapgTo0LKXX1VUZ0Dxr+e8N0WbS80nSZ5+zf3lvpf8qxrkZdqkOqFfm57gXwA==", "cpu": [ "x64" ], @@ -3452,9 +3452,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.1.tgz", + "integrity": "sha512-CPWs0HTFe5woTJN5eKPvgraUoRHrCtzlYIAv9wBC+FAyagBSaf+UdZrjwYyTGnwPGkThV4OCI7XibZOnPvONVw==", "cpu": [ "x64" ], @@ -3467,9 +3467,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.1.tgz", + "integrity": "sha512-xxhTm5QtzNLc24R0hEkcH+zCx/o49AsdFZ0Cy5zSd/5tOj4X2g3/2AJB625NoadUuc4A8B3TenLJoYdWYOYCew==", "cpu": [ "x64" ], @@ -3482,9 +3482,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.1.tgz", + "integrity": "sha512-CWibXszpWys1pYmbr9UiKAkX6x+Sxw8HWtw1dRESK1dLW5fFJ6rMDVw0o8MbadusvVQx1a8xuOxnHXT941Hp1A==", "cpu": [ "x64" ], @@ -3497,9 +3497,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.1.tgz", + "integrity": "sha512-jb5B4k+xkytGbGUS4T+Z89cQJ9DJ4lozGRSV+hhfmCPpfJ3880O31Q1srPCimm+V6UCbnigqD10EgDNgjvjerQ==", "cpu": [ "arm64" ], @@ -3512,9 +3512,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.1.tgz", + "integrity": "sha512-PgyFvjJhXqHn1uxPhyN1wZ6dIomKjiLUQh1LjFvjiV1JmnkZ/oMPrfeEAZg5R/1ftz4LZWZr02kefNIQ5SKREQ==", "cpu": [ "ia32" ], @@ -3527,9 +3527,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.1.tgz", + "integrity": "sha512-W9NttRZQR5ehAiqHGDnvfDaGmQOm6Fi4vSlce8mjM75x//XKuVAByohlEX6N17yZnVXxQFuh4fDRunP8ca6bfA==", "cpu": [ "x64" ], @@ -8029,9 +8029,9 @@ "peer": true }, "node_modules/cypress": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.8.0.tgz", - "integrity": "sha512-Qau//mtrwEGOU9cn2YjavECKyDUwBh8J2tit+y9s1wsv6C3BX+rlv6I9afmQnL8PmEEzJ6be7nppMHacFzZkTw==", + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.9.0.tgz", + "integrity": "sha512-atNjmYfHsvTuCaxTxLZr9xGoHz53LLui3266WWxXJHY7+N6OdwJdg/feEa3T+buez9dmUXHT1izCOklqG82uCQ==", "hasInstallScript": true, "optional": true, "dependencies": { @@ -9197,9 +9197,9 @@ } }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.1.tgz", + "integrity": "sha512-GPqx+FX7mdqulCeQ4TsGZQ3djBJkx5k7zBGtqt9ycVlWNg8llJ4RO9n2vciu8BN2zAEs6lPbPl0asZsAh7oWzg==", "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -9208,29 +9208,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.1", + "@esbuild/android-arm": "0.21.1", + "@esbuild/android-arm64": "0.21.1", + "@esbuild/android-x64": "0.21.1", + "@esbuild/darwin-arm64": "0.21.1", + "@esbuild/darwin-x64": "0.21.1", + "@esbuild/freebsd-arm64": "0.21.1", + "@esbuild/freebsd-x64": "0.21.1", + "@esbuild/linux-arm": "0.21.1", + "@esbuild/linux-arm64": "0.21.1", + "@esbuild/linux-ia32": "0.21.1", + "@esbuild/linux-loong64": "0.21.1", + "@esbuild/linux-mips64el": "0.21.1", + "@esbuild/linux-ppc64": "0.21.1", + "@esbuild/linux-riscv64": "0.21.1", + "@esbuild/linux-s390x": "0.21.1", + "@esbuild/linux-x64": "0.21.1", + "@esbuild/netbsd-x64": "0.21.1", + "@esbuild/openbsd-x64": "0.21.1", + "@esbuild/sunos-x64": "0.21.1", + "@esbuild/win32-arm64": "0.21.1", + "@esbuild/win32-ia32": "0.21.1", + "@esbuild/win32-x64": "0.21.1" } }, "node_modules/esbuild-wasm": { @@ -20563,141 +20563,141 @@ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==" }, "@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.1.tgz", + "integrity": "sha512-O7yppwipkXvnEPjzkSXJRk2g4bS8sUx9p9oXHq9MU/U7lxUzZVsnFZMDTmeeX9bfQxrFcvOacl/ENgOh0WP9pA==", "optional": true }, "@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.1.tgz", + "integrity": "sha512-hh3jKWikdnTtHCglDAeVO3Oyh8MaH8xZUaWMiCCvJ9/c3NtPqZq+CACOlGTxhddypXhl+8B45SeceYBfB/e8Ow==", "optional": true }, "@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.1.tgz", + "integrity": "sha512-jXhccq6es+onw7x8MxoFnm820mz7sGa9J14kLADclmiEUH4fyj+FjR6t0M93RgtlI/awHWhtF0Wgfhqgf9gDZA==", "optional": true }, "@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.1.tgz", + "integrity": "sha512-NPObtlBh4jQHE01gJeucqEhdoD/4ya2owSIS8lZYS58aR0x7oZo9lB2lVFxgTANSa5MGCBeoQtr+yA9oKCGPvA==", "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.1.tgz", + "integrity": "sha512-BLT7TDzqsVlQRmJfO/FirzKlzmDpBWwmCUlyggfzUwg1cAxVxeA4O6b1XkMInlxISdfPAOunV9zXjvh5x99Heg==", "optional": true }, "@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.1.tgz", + "integrity": "sha512-D3h3wBQmeS/vp93O4B+SWsXB8HvRDwMyhTNhBd8yMbh5wN/2pPWRW5o/hM3EKgk9bdKd9594lMGoTCTiglQGRQ==", "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.1.tgz", + "integrity": "sha512-/uVdqqpNKXIxT6TyS/oSK4XE4xWOqp6fh4B5tgAwozkyWdylcX+W4YF2v6SKsL4wCQ5h1bnaSNjWPXG/2hp8AQ==", "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.1.tgz", + "integrity": "sha512-paAkKN1n1jJitw+dAoR27TdCzxRl1FOEITx3h201R6NoXUojpMzgMLdkXVgCvaCSCqwYkeGLoe9UVNRDKSvQgw==", "optional": true }, "@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.1.tgz", + "integrity": "sha512-tRHnxWJnvNnDpNVnsyDhr1DIQZUfCXlHSCDohbXFqmg9W4kKR7g8LmA3kzcwbuxbRMKeit8ladnCabU5f2traA==", "optional": true }, "@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.1.tgz", + "integrity": "sha512-G65d08YoH00TL7Xg4LaL3gLV21bpoAhQ+r31NUu013YB7KK0fyXIt05VbsJtpqh/6wWxoLJZOvQHYnodRrnbUQ==", "optional": true }, "@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.1.tgz", + "integrity": "sha512-tt/54LqNNAqCz++QhxoqB9+XqdsaZOtFD/srEhHYwBd3ZUOepmR1Eeot8bS+Q7BiEvy9vvKbtpHf+r6q8hF5UA==", "optional": true }, "@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.1.tgz", + "integrity": "sha512-MhNalK6r0nZD0q8VzUBPwheHzXPr9wronqmZrewLfP7ui9Fv1tdPmg6e7A8lmg0ziQCziSDHxh3cyRt4YMhGnQ==", "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.1.tgz", + "integrity": "sha512-YCKVY7Zen5rwZV+nZczOhFmHaeIxR4Zn3jcmNH53LbgF6IKRwmrMywqDrg4SiSNApEefkAbPSIzN39FC8VsxPg==", "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.1.tgz", + "integrity": "sha512-bw7bcQ+270IOzDV4mcsKAnDtAFqKO0jVv3IgRSd8iM0ac3L8amvCrujRVt1ajBTJcpDaFhIX+lCNRKteoDSLig==", "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.1.tgz", + "integrity": "sha512-ARmDRNkcOGOm1AqUBSwRVDfDeD9hGYRfkudP2QdoonBz1ucWVnfBPfy7H4JPI14eYtZruRSczJxyu7SRYDVOcg==", "optional": true }, "@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.1.tgz", + "integrity": "sha512-o73TcUNMuoTZlhwFdsgr8SfQtmMV58sbgq6gQq9G1xUiYnHMTmJbwq65RzMx89l0iya69lR4bxBgtWiiOyDQZA==", "optional": true }, "@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.1.tgz", + "integrity": "sha512-da4/1mBJwwgJkbj4fMH7SOXq2zapgTo0LKXX1VUZ0Dxr+e8N0WbS80nSZ5+zf3lvpf8qxrkZdqkOqFfm57gXwA==", "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.1.tgz", + "integrity": "sha512-CPWs0HTFe5woTJN5eKPvgraUoRHrCtzlYIAv9wBC+FAyagBSaf+UdZrjwYyTGnwPGkThV4OCI7XibZOnPvONVw==", "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.1.tgz", + "integrity": "sha512-xxhTm5QtzNLc24R0hEkcH+zCx/o49AsdFZ0Cy5zSd/5tOj4X2g3/2AJB625NoadUuc4A8B3TenLJoYdWYOYCew==", "optional": true }, "@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.1.tgz", + "integrity": "sha512-CWibXszpWys1pYmbr9UiKAkX6x+Sxw8HWtw1dRESK1dLW5fFJ6rMDVw0o8MbadusvVQx1a8xuOxnHXT941Hp1A==", "optional": true }, "@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.1.tgz", + "integrity": "sha512-jb5B4k+xkytGbGUS4T+Z89cQJ9DJ4lozGRSV+hhfmCPpfJ3880O31Q1srPCimm+V6UCbnigqD10EgDNgjvjerQ==", "optional": true }, "@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.1.tgz", + "integrity": "sha512-PgyFvjJhXqHn1uxPhyN1wZ6dIomKjiLUQh1LjFvjiV1JmnkZ/oMPrfeEAZg5R/1ftz4LZWZr02kefNIQ5SKREQ==", "optional": true }, "@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.1.tgz", + "integrity": "sha512-W9NttRZQR5ehAiqHGDnvfDaGmQOm6Fi4vSlce8mjM75x//XKuVAByohlEX6N17yZnVXxQFuh4fDRunP8ca6bfA==", "optional": true }, "@eslint-community/eslint-utils": { @@ -24112,9 +24112,9 @@ "peer": true }, "cypress": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.8.0.tgz", - "integrity": "sha512-Qau//mtrwEGOU9cn2YjavECKyDUwBh8J2tit+y9s1wsv6C3BX+rlv6I9afmQnL8PmEEzJ6be7nppMHacFzZkTw==", + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.9.0.tgz", + "integrity": "sha512-atNjmYfHsvTuCaxTxLZr9xGoHz53LLui3266WWxXJHY7+N6OdwJdg/feEa3T+buez9dmUXHT1izCOklqG82uCQ==", "optional": true, "requires": { "@cypress/request": "^3.0.0", @@ -25032,33 +25032,33 @@ } }, "esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.1.tgz", + "integrity": "sha512-GPqx+FX7mdqulCeQ4TsGZQ3djBJkx5k7zBGtqt9ycVlWNg8llJ4RO9n2vciu8BN2zAEs6lPbPl0asZsAh7oWzg==", "requires": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.1", + "@esbuild/android-arm": "0.21.1", + "@esbuild/android-arm64": "0.21.1", + "@esbuild/android-x64": "0.21.1", + "@esbuild/darwin-arm64": "0.21.1", + "@esbuild/darwin-x64": "0.21.1", + "@esbuild/freebsd-arm64": "0.21.1", + "@esbuild/freebsd-x64": "0.21.1", + "@esbuild/linux-arm": "0.21.1", + "@esbuild/linux-arm64": "0.21.1", + "@esbuild/linux-ia32": "0.21.1", + "@esbuild/linux-loong64": "0.21.1", + "@esbuild/linux-mips64el": "0.21.1", + "@esbuild/linux-ppc64": "0.21.1", + "@esbuild/linux-riscv64": "0.21.1", + "@esbuild/linux-s390x": "0.21.1", + "@esbuild/linux-x64": "0.21.1", + "@esbuild/netbsd-x64": "0.21.1", + "@esbuild/openbsd-x64": "0.21.1", + "@esbuild/sunos-x64": "0.21.1", + "@esbuild/win32-arm64": "0.21.1", + "@esbuild/win32-ia32": "0.21.1", + "@esbuild/win32-x64": "0.21.1" } }, "esbuild-wasm": { diff --git a/frontend/package.json b/frontend/package.json index adc33cb00..4c9e63b8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,16 +50,16 @@ "dev:ssr": "npm run generate-config && ng run mempool:serve-ssr", "serve:ssr": "npm run generate-config && node server.run.js", "build:ssr": "npm run build && ng run mempool:server:production && ./node_modules/typescript/bin/tsc server.run.ts", - "config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", - "config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config", + "config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", + "config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config", "prerender": "npm run ng -- run mempool:prerender", "cypress:open": "cypress open", "cypress:run": "cypress run", "cypress:run:record": "cypress run --record", - "cypress:open:ci": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:open", - "cypress:run:ci": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:run:record", - "cypress:open:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:open", - "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" + "cypress:open:ci": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:open", + "cypress:run:ci": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:run:record", + "cypress:open:ci:staging": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:open", + "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" }, "dependencies": { "@angular-devkit/build-angular": "^17.3.1", @@ -92,7 +92,7 @@ "ngx-infinite-scroll": "^17.0.0", "qrcode": "1.5.1", "rxjs": "~7.8.1", - "esbuild": "^0.20.2", + "esbuild": "^0.21.1", "tinyify": "^4.0.0", "tlite": "^0.1.9", "tslib": "~2.6.0", @@ -115,7 +115,7 @@ "optionalDependencies": { "@cypress/schematic": "^2.5.0", "@types/cypress": "^1.1.3", - "cypress": "^13.8.0", + "cypress": "^13.9.0", "cypress-fail-on-console-error": "~5.1.0", "cypress-wait-until": "^2.0.1", "mock-socket": "~9.3.1", diff --git a/frontend/proxy.conf.js b/frontend/proxy.conf.js index b63d343e2..05f7550e0 100644 --- a/frontend/proxy.conf.js +++ b/frontend/proxy.conf.js @@ -24,7 +24,7 @@ PROXY_CONFIG = [ '/api/**', '!/api/v1/ws', '!/liquid', '!/liquid/**', '!/liquid/', '!/liquidtestnet', '!/liquidtestnet/**', '!/liquidtestnet/', - '/testnet/api/**', '/signet/api/**' + '/testnet/api/**', '/signet/api/**', '/testnet4/api/**' ], target: "https://mempool.space", ws: true, diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 7a23e7556..4fd1d2013 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -53,6 +53,44 @@ let routes: Routes = [ }, ] }, + { + path: 'testnet4', + children: [ + { + path: '', + pathMatch: 'full', + loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), + data: { preload: true }, + }, + { + path: '', + loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), + data: { preload: true }, + }, + { + path: 'wallet', + children: [], + component: AddressGroupComponent, + data: { + networkSpecific: true, + } + }, + { + path: 'status', + data: { networks: ['bitcoin', 'liquid'] }, + component: StatusViewComponent + }, + { + path: '', + loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule), + data: { preload: true }, + }, + { + path: '**', + redirectTo: '/testnet4' + }, + ] + }, { path: 'signet', children: [ @@ -130,6 +168,10 @@ let routes: Routes = [ path: 'testnet', loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) }, + { + path: 'testnet4', + loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) + }, { path: 'signet', loadChildren: () => import('./previews.module').then(m => m.PreviewsModule) diff --git a/frontend/src/app/app.constants.ts b/frontend/src/app/app.constants.ts index bd81d02c0..aaa53b8ba 100644 --- a/frontend/src/app/app.constants.ts +++ b/frontend/src/app/app.constants.ts @@ -189,22 +189,22 @@ export const specialBlocks = { '0': { labelEvent: 'Genesis', labelEventCompleted: 'The Genesis of Bitcoin', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '210000': { labelEvent: 'Bitcoin\'s 1st Halving', labelEventCompleted: 'Block Subsidy has halved to 25 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '420000': { labelEvent: 'Bitcoin\'s 2nd Halving', labelEventCompleted: 'Block Subsidy has halved to 12.5 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '630000': { labelEvent: 'Bitcoin\'s 3rd Halving', labelEventCompleted: 'Block Subsidy has halved to 6.25 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '709632': { labelEvent: 'Taproot 🌱 activation', @@ -214,62 +214,62 @@ export const specialBlocks = { '840000': { labelEvent: 'Bitcoin\'s 4th Halving', labelEventCompleted: 'Block Subsidy has halved to 3.125 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1050000': { labelEvent: 'Bitcoin\'s 5th Halving', labelEventCompleted: 'Block Subsidy has halved to 1.5625 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1260000': { labelEvent: 'Bitcoin\'s 6th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.78125 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1470000': { labelEvent: 'Bitcoin\'s 7th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.390625 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1680000': { labelEvent: 'Bitcoin\'s 8th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.1953125 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '1890000': { labelEvent: 'Bitcoin\'s 9th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.09765625 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2100000': { labelEvent: 'Bitcoin\'s 10th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.04882812 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2310000': { labelEvent: 'Bitcoin\'s 11th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.02441406 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2520000': { labelEvent: 'Bitcoin\'s 12th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.01220703 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2730000': { labelEvent: 'Bitcoin\'s 13th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.00610351 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '2940000': { labelEvent: 'Bitcoin\'s 14th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.00305175 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], }, '3150000': { labelEvent: 'Bitcoin\'s 15th Halving', labelEventCompleted: 'Block Subsidy has halved to 0.00152587 BTC per block', - networks: ['mainnet', 'testnet'], + networks: ['mainnet', 'testnet', 'testnet4'], } }; diff --git a/frontend/src/app/bitcoin.utils.ts b/frontend/src/app/bitcoin.utils.ts index d5b5122fa..b9f4f39e1 100644 --- a/frontend/src/app/bitcoin.utils.ts +++ b/frontend/src/app/bitcoin.utils.ts @@ -266,6 +266,11 @@ const featureActivation = { segwit: 872730, taproot: 2032291, }, + testnet4: { + rbf: 0, + segwit: 0, + taproot: 0, + }, signet: { rbf: 0, segwit: 0, diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 5185c9d01..452140d65 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -343,8 +343,8 @@ - - + + diff --git a/frontend/src/app/components/about/about.component.scss b/frontend/src/app/components/about/about.component.scss index 81fcfbbd8..d1c15f838 100644 --- a/frontend/src/app/components/about/about.component.scss +++ b/frontend/src/app/components/about/about.component.scss @@ -129,8 +129,9 @@ position: relative; width: 300px; } - .bisq { - top: 3px; + .sv { + height: 85px; + width: auto; position: relative; } } diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html index e9f6badbd..44b027ae2 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -23,7 +23,7 @@ Accelerate Confirmation expected within ~30 minutes
@if (!calculating) { - fee ({{ cost | number }} sats) + fee ({{ cost | number }} sats) } @else { Calculating cost... } diff --git a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss index 96273dead..984bc2b4c 100644 --- a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss +++ b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; diff --git a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts index f2c082fc8..237b14317 100644 --- a/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts +++ b/frontend/src/app/components/acceleration/accelerations-list/accelerations-list.component.ts @@ -1,5 +1,5 @@ -import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core'; -import { combineLatest, BehaviorSubject, Observable, catchError, of, switchMap, tap } from 'rxjs'; +import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy } from '@angular/core'; +import { BehaviorSubject, Observable, catchError, of, switchMap, tap } from 'rxjs'; import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; import { StateService } from '../../../services/state.service'; import { WebsocketService } from '../../../services/websocket.service'; @@ -11,7 +11,7 @@ import { ServicesApiServices } from '../../../services/services-api.service'; styleUrls: ['./accelerations-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AccelerationsListComponent implements OnInit { +export class AccelerationsListComponent implements OnInit, OnDestroy { @Input() widget: boolean = false; @Input() pending: boolean = false; @Input() accelerations$: Observable; @@ -44,7 +44,10 @@ export class AccelerationsListComponent implements OnInit { this.accelerationList$ = this.pageSubject.pipe( switchMap((page) => { - const accelerationObservable$ = this.accelerations$ || (this.pending ? this.servicesApiService.getAccelerations$() : this.servicesApiService.getAccelerationHistoryObserveResponse$({ page: page })); + const accelerationObservable$ = this.accelerations$ || (this.pending ? this.stateService.liveAccelerations$ : this.servicesApiService.getAccelerationHistoryObserveResponse$({ page: page })); + if (!this.accelerations$ && this.pending) { + this.websocketService.ensureTrackAccelerations(); + } return accelerationObservable$.pipe( switchMap(response => { let accelerations = response; @@ -85,4 +88,8 @@ export class AccelerationsListComponent implements OnInit { trackByBlock(index: number, block: BlockExtended): number { return block.height; } + + ngOnDestroy(): void { + this.websocketService.stopTrackAccelerations(); + } } \ No newline at end of file diff --git a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss index 563e189cf..e6763f60a 100644 --- a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss +++ b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.scss @@ -60,7 +60,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; diff --git a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts index dc53d8f95..5f9017bbd 100644 --- a/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts +++ b/frontend/src/app/components/acceleration/accelerator-dashboard/accelerator-dashboard.component.ts @@ -1,10 +1,10 @@ -import { ChangeDetectionStrategy, Component, HostListener, Inject, OnInit, PLATFORM_ID } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { SeoService } from '../../../services/seo.service'; import { OpenGraphService } from '../../../services/opengraph.service'; import { WebsocketService } from '../../../services/websocket.service'; import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; import { StateService } from '../../../services/state.service'; -import { Observable, catchError, combineLatest, distinctUntilChanged, interval, map, of, share, startWith, switchMap, tap } from 'rxjs'; +import { Observable, Subscription, catchError, combineLatest, distinctUntilChanged, map, of, share, switchMap, tap } from 'rxjs'; import { Color } from '../../block-overview-graph/sprite-types'; import { hexToColor } from '../../block-overview-graph/utils'; import TxView from '../../block-overview-graph/tx-view'; @@ -28,7 +28,7 @@ interface AccelerationBlock extends BlockExtended { styleUrls: ['./accelerator-dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AcceleratorDashboardComponent implements OnInit { +export class AcceleratorDashboardComponent implements OnInit, OnDestroy { blocks$: Observable; accelerations$: Observable; pendingAccelerations$: Observable; @@ -39,6 +39,8 @@ export class AcceleratorDashboardComponent implements OnInit { firstLoad = true; timespan: '3d' | '1w' | '1m' = '1w'; + accelerationDeltaSubscription: Subscription; + graphHeight: number = 300; theme: ThemeService; @@ -59,27 +61,28 @@ export class AcceleratorDashboardComponent implements OnInit { ngOnInit(): void { this.onResize(); this.websocketService.want(['blocks', 'mempool-blocks', 'stats']); + this.websocketService.startTrackAccelerations(); - this.pendingAccelerations$ = (this.stateService.isBrowser ? interval(30000) : of(null)).pipe( - startWith(true), - switchMap(() => { - return this.serviceApiServices.getAccelerations$().pipe( - catchError(() => { - return of([]); - }), - ); - }), - tap(accelerations => { - if (!this.firstLoad && accelerations.some(acc => !this.seen.has(acc.txid))) { - this.audioService.playSound('bright-harmony'); - } - for(const acc of accelerations) { - this.seen.add(acc.txid); - } - this.firstLoad = false; - }), + this.pendingAccelerations$ = this.stateService.liveAccelerations$.pipe( share(), ); + this.accelerationDeltaSubscription = this.stateService.accelerations$.subscribe((delta) => { + if (!delta.reset) { + let hasNewAcceleration = false; + for (const acc of delta.added) { + if (!this.seen.has(acc.txid)) { + hasNewAcceleration = true; + } + this.seen.add(acc.txid); + } + for (const txid of delta.removed) { + this.seen.delete(txid); + } + if (hasNewAcceleration) { + this.audioService.playSound('bright-harmony'); + } + } + }); this.accelerations$ = this.stateService.chainTip$.pipe( distinctUntilChanged(), @@ -145,7 +148,7 @@ export class AcceleratorDashboardComponent implements OnInit { } else { const rate = tx.fee / tx.vsize; // color by simple single-tx fee rate const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1; - return this.theme.theme === 'contrast' ? contrastColors[feeLevelIndex] || contrastColors[contrastColors.length - 1] : normalColors[feeLevelIndex] || normalColors[normalColors.length - 1]; + return this.theme.theme === 'contrast' || this.theme.theme === 'bukele' ? contrastColors[feeLevelIndex] || contrastColors[contrastColors.length - 1] : normalColors[feeLevelIndex] || normalColors[normalColors.length - 1]; } } @@ -154,6 +157,11 @@ export class AcceleratorDashboardComponent implements OnInit { return false; } + ngOnDestroy(): void { + this.accelerationDeltaSubscription.unsubscribe(); + this.websocketService.stopTrackAccelerations(); + } + @HostListener('window:resize', ['$event']) onResize(): void { if (window.innerWidth >= 992) { diff --git a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts index ed7061156..568e60d7e 100644 --- a/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts +++ b/frontend/src/app/components/acceleration/pending-stats/pending-stats.component.ts @@ -2,7 +2,8 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core import { Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { Acceleration } from '../../../interfaces/node-api.interface'; -import { ServicesApiServices } from '../../../services/services-api.service'; +import { StateService } from '../../../services/state.service'; +import { WebsocketService } from '../../../services/websocket.service'; @Component({ selector: 'app-pending-stats', @@ -15,11 +16,12 @@ export class PendingStatsComponent implements OnInit { public accelerationStats$: Observable; constructor( - private servicesApiService: ServicesApiServices, + private stateService: StateService, + private websocketService: WebsocketService, ) { } ngOnInit(): void { - this.accelerationStats$ = (this.accelerations$ || this.servicesApiService.getAccelerations$()).pipe( + this.accelerationStats$ = (this.accelerations$ || this.stateService.liveAccelerations$).pipe( switchMap(accelerations => { let totalAccelerations = 0; let totalFeeDelta = 0; diff --git a/frontend/src/app/components/address-graph/address-graph.component.html b/frontend/src/app/components/address-graph/address-graph.component.html index 35808cb14..32e16913a 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.html +++ b/frontend/src/app/components/address-graph/address-graph.component.html @@ -1,14 +1,8 @@ - - -
-
-
- Balance History -
-
+ +
-
@@ -20,4 +14,8 @@

{{ error }}

+ +
+
+
diff --git a/frontend/src/app/components/address-graph/address-graph.component.scss b/frontend/src/app/components/address-graph/address-graph.component.scss index d23b95d8d..62393644b 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.scss +++ b/frontend/src/app/components/address-graph/address-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -45,28 +46,12 @@ display: flex; flex: 1; width: 100%; - padding-bottom: 20px; + padding-bottom: 10px; padding-right: 10px; - @media (max-width: 992px) { - padding-bottom: 25px; - } - @media (max-width: 829px) { - padding-bottom: 50px; - } - @media (max-width: 767px) { - padding-bottom: 25px; - } - @media (max-width: 629px) { - padding-bottom: 55px; - } - @media (max-width: 567px) { - padding-bottom: 55px; - } } .chart-widget { width: 100%; height: 100%; - max-height: 270px; } .disabled { diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index 6ae3dd8e8..842e96cdd 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -1,12 +1,22 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, SimpleChanges } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { echarts, EChartsOption } from '../../graphs/echarts'; -import { of } from 'rxjs'; +import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { ChainStats } from '../../interfaces/electrs.interface'; +import { AddressTxSummary, ChainStats } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; import { Router } from '@angular/router'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; +import { StateService } from '../../services/state.service'; + +const periodSeconds = { + '1d': (60 * 60 * 24), + '3d': (60 * 60 * 24 * 3), + '1w': (60 * 60 * 24 * 7), + '1m': (60 * 60 * 24 * 30), + '6m': (60 * 60 * 24 * 180), + '1y': (60 * 60 * 24 * 365), +}; @Component({ selector: 'app-address-graph', @@ -22,16 +32,23 @@ import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pi `], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AddressGraphComponent implements OnChanges { +export class AddressGraphComponent implements OnChanges, OnDestroy { @Input() address: string; @Input() isPubkey: boolean = false; @Input() stats: ChainStats; + @Input() addressSummary$: Observable | null; + @Input() period: '1d' | '3d' | '1w' | '1m' | '6m' | '1y' | 'all' = 'all'; + @Input() height: number = 200; @Input() right: number | string = 10; @Input() left: number | string = 70; + @Input() widget: boolean = false; data: any[] = []; hoverData: any[] = []; + subscription: Subscription; + redraw$: BehaviorSubject = new BehaviorSubject(false); + chartOptions: EChartsOption = {}; chartInitOptions = { renderer: 'svg', @@ -43,6 +60,7 @@ export class AddressGraphComponent implements OnChanges { constructor( @Inject(LOCALE_ID) public locale: string, + public stateService: StateService, private electrsApiService: ElectrsApiService, private router: Router, private amountShortenerPipe: AmountShortenerPipe, @@ -52,32 +70,59 @@ export class AddressGraphComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { this.isLoading = true; - (this.isPubkey - ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') - : this.electrsApiService.getAddressSummary$(this.address)).pipe( - catchError(e => { - this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; - return of(null); - }), - ).subscribe(addressSummary => { - if (addressSummary) { - this.error = null; - this.prepareChartOptions(addressSummary); + if (!this.address || !this.stats) { + return; + } + if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) { + if (this.subscription) { + this.subscription.unsubscribe(); } - this.isLoading = false; - this.cd.markForCheck(); - }); + this.subscription = combineLatest([ + this.redraw$, + (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }), + )) + ]).subscribe(([redraw, addressSummary]) => { + if (addressSummary) { + this.error = null; + this.prepareChartOptions(addressSummary); + } + this.isLoading = false; + this.cd.markForCheck(); + }); + } else { + // re-trigger subscription + this.redraw$.next(true); + } } prepareChartOptions(summary): void { - let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); // + (summary[0]?.value || 0); + if (!summary || !this.stats) { + return; + } + let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); this.data = summary.map(d => { const balance = total; total -= d.value; return [d.time * 1000, balance, d]; }).reverse(); - const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1])), 0); + if (this.period !== 'all') { + const now = Date.now(); + const start = now - (periodSeconds[this.period] * 1000); + this.data = this.data.filter(d => d[0] >= start); + this.data.push( + {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }} + ); + } + + const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0); + const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue); this.chartOptions = { color: [ @@ -108,6 +153,9 @@ export class AddressGraphComponent implements OnChanges { }, borderColor: '#000', formatter: function (data): string { + if (!data?.length || !data[0]?.data?.[2]?.txid) { + return ''; + } const header = data.length === 1 ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` : `${data.length} transactions`; @@ -141,13 +189,17 @@ export class AddressGraphComponent implements OnChanges { axisLabel: { color: 'rgb(110, 112, 121)', formatter: (val): string => { - if (maxValue > 1_000_000_000) { + let valSpan = maxValue - (this.period === 'all' ? 0 : minValue); + if (valSpan > 100_000_000_000) { return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`; - } else if (maxValue > 100_000_000) { + } + else if (valSpan > 1_000_000_000) { + return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2)} BTC`; + } else if (valSpan > 100_000_000) { return `${(val / 100_000_000).toFixed(1)} BTC`; - } else if (maxValue > 10_000_000) { + } else if (valSpan > 10_000_000) { return `${(val / 100_000_000).toFixed(2)} BTC`; - } else if (maxValue > 1_000_000) { + } else if (valSpan > 1_000_000) { return `${(val / 100_000_000).toFixed(3)} BTC`; } else { return `${this.amountShortenerPipe.transform(val, 0)} sats`; @@ -157,6 +209,7 @@ export class AddressGraphComponent implements OnChanges { splitLine: { show: false, }, + min: this.period === 'all' ? 0 : 'dataMin' }, ], series: [ @@ -194,6 +247,12 @@ export class AddressGraphComponent implements OnChanges { this.chartInstance.on('click', 'series', this.onChartClick.bind(this)); } + ngOnDestroy(): void { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + isMobile() { return (window.innerWidth <= 767.98); } diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html new file mode 100644 index 000000000..13fe5b0d3 --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + +
 
+
TXIDAmount{{ currency }}Date
+ + + +
+ + + + +
+
+
+
+ + +
\ No newline at end of file diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss new file mode 100644 index 000000000..851da5996 --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss @@ -0,0 +1,50 @@ +.latest-transactions { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-satoshis { + display: none; + text-align: right; + @media (min-width: 576px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 1100px) { + display: table-cell; + } + } + .table-cell-fiat { + display: none; + text-align: right; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + .table-cell-date { + text-align: right; + } +} +.skeleton-loader-transactions { + max-width: 250px; + position: relative; + top: 2px; + margin-bottom: -3px; + height: 18px; +} \ No newline at end of file diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts new file mode 100644 index 000000000..c3fc4260e --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts @@ -0,0 +1,76 @@ +import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { Observable, Subscription, catchError, map, of, switchMap, zip } from 'rxjs'; +import { PriceService } from '../../services/price.service'; + +@Component({ + selector: 'app-address-transactions-widget', + templateUrl: './address-transactions-widget.component.html', + styleUrls: ['./address-transactions-widget.component.scss'], +}) +export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, OnDestroy { + @Input() address: string; + @Input() addressInfo: Address; + @Input() addressSummary$: Observable | null; + @Input() isPubkey: boolean = false; + + currencySubscription: Subscription; + currency: string; + + transactions$: Observable; + + isLoading: boolean = true; + error: any; + + constructor( + public stateService: StateService, + private electrsApiService: ElectrsApiService, + private priceService: PriceService, + ) { } + + ngOnInit(): void { + this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { + this.currency = fiat; + }); + this.startAddressSubscription(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.startAddressSubscription(); + } + + startAddressSubscription(): void { + this.isLoading = true; + if (!this.address || !this.addressInfo) { + return; + } + this.transactions$ = (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }) + )).pipe( + map(summary => { + return summary?.slice(0, 6); + }), + switchMap(txs => { + return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, true, this.currency).pipe( + map(price => { + return { + ...tx, + price, + }; + }) + )))); + }) + ); + } + + ngOnDestroy(): void { + this.currencySubscription.unsubscribe(); + } +} diff --git a/frontend/src/app/components/address/address-preview.component.scss b/frontend/src/app/components/address/address-preview.component.scss index 623d72db2..f03e13541 100644 --- a/frontend/src/app/components/address/address-preview.component.scss +++ b/frontend/src/app/components/address/address-preview.component.scss @@ -3,7 +3,7 @@ } .qr-wrapper { - background-color: var(--fg); + background-color: #fff; padding: 10px; padding-bottom: 5px; display: inline-block; diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index 531b97464..661e84869 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -53,10 +53,20 @@
+
+

Balance History

+
+
+ all + | + recent +
- +
diff --git a/frontend/src/app/components/address/address.component.scss b/frontend/src/app/components/address/address.component.scss index 7107c73f2..da615376c 100644 --- a/frontend/src/app/components/address/address.component.scss +++ b/frontend/src/app/components/address/address.component.scss @@ -1,5 +1,5 @@ .qr-wrapper { - background-color: var(--fg); + background-color: #fff; padding: 10px; padding-bottom: 5px; display: inline-block; @@ -109,3 +109,19 @@ h1 { flex-grow: 0.5; } } + +.widget-toggler { + font-size: 12px; + position: absolute; + top: -20px; + right: 3px; + text-align: right; +} + +.toggler-option { + text-decoration: none; +} + +.inactive { + color: var(--transparent-fg); +} \ No newline at end of file diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index 95abe4ac1..9ef29b423 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -38,6 +38,8 @@ export class AddressComponent implements OnInit, OnDestroy { txCount = 0; received = 0; sent = 0; + now = Date.now() / 1000; + balancePeriod: 'all' | '1m' = 'all'; private tempTransactions: Transaction[]; private timeTxIndexes: number[]; @@ -173,7 +175,14 @@ export class AddressComponent implements OnInit, OnDestroy { }); this.transactions = this.tempTransactions; + if (this.transactions.length === this.txCount) { + this.fullyLoaded = true; + } this.isLoadingTransactions = false; + + if (!this.showBalancePeriod()) { + this.setBalancePeriod('all'); + } }, (error) => { console.log(error); @@ -296,6 +305,18 @@ export class AddressComponent implements OnInit, OnDestroy { this.txCount = this.address.chain_stats.tx_count + this.address.mempool_stats.tx_count; } + setBalancePeriod(period: 'all' | '1m'): boolean { + this.balancePeriod = period; + return false; + } + + showBalancePeriod(): boolean { + return this.transactions?.length && ( + !this.transactions[0].status?.confirmed + || this.transactions[0].status.block_time > (this.now - (60 * 60 * 24 * 30)) + ); + } + ngOnDestroy() { this.mainSubscription.unsubscribe(); this.mempoolTxSubscription.unsubscribe(); diff --git a/frontend/src/app/components/amount/amount.component.html b/frontend/src/app/components/amount/amount.component.html index 9ca0ba939..b38cf4c41 100644 --- a/frontend/src/app/components/amount/amount.component.html +++ b/frontend/src/app/components/amount/amount.component.html @@ -1,4 +1,4 @@ - + {{ addPlus && satoshis >= 0 ? '+' : '' }}{{ ( @@ -20,10 +20,29 @@ Confidential - ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }} - L- - tL- - t - sBTC + + @if ((viewAmountMode$ | async) === 'btc' || (viewAmountMode$ | async) === 'fiat') { + ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }} + + BTC + + } @else { + @if (digitsInfo === '1.8-8') { + ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | number }} + } @else { + ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | amountShortener : satoshis < 1000 && satoshis > -1000 ? 0 : 1 }} + } + + sats + + } + + + L- + tL- + t + t + s + diff --git a/frontend/src/app/components/amount/amount.component.ts b/frontend/src/app/components/amount/amount.component.ts index 9d0337574..60cbe3117 100644 --- a/frontend/src/app/components/amount/amount.component.ts +++ b/frontend/src/app/components/amount/amount.component.ts @@ -12,7 +12,7 @@ import { Price } from '../../services/price.service'; export class AmountComponent implements OnInit, OnDestroy { conversions$: Observable; currency: string; - viewFiat$: Observable; + viewAmountMode$: Observable<'btc' | 'sats' | 'fiat'>; network = ''; stateSubscription: Subscription; @@ -37,7 +37,7 @@ export class AmountComponent implements OnInit, OnDestroy { } ngOnInit() { - this.viewFiat$ = this.stateService.viewFiat$.asObservable(); + this.viewAmountMode$ = this.stateService.viewAmountMode$.asObservable(); this.conversions$ = this.stateService.conversions$.asObservable(); this.stateSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network); } diff --git a/frontend/src/app/components/app/app.component.ts b/frontend/src/app/components/app/app.component.ts index ace0122f0..453276966 100644 --- a/frontend/src/app/components/app/app.component.ts +++ b/frontend/src/app/components/app/app.component.ts @@ -4,6 +4,8 @@ import { Router, NavigationEnd } from '@angular/router'; import { StateService } from '../../services/state.service'; import { OpenGraphService } from '../../services/opengraph.service'; import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap'; +import { ThemeService } from '../../services/theme.service'; +import { SeoService } from '../../services/seo.service'; @Component({ selector: 'app-root', @@ -12,12 +14,12 @@ import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap'; providers: [NgbTooltipConfig] }) export class AppComponent implements OnInit { - link: HTMLElement = document.getElementById('canonical'); - constructor( public router: Router, private stateService: StateService, private openGraphService: OpenGraphService, + private seoService: SeoService, + private themeService: ThemeService, private location: Location, tooltipConfig: NgbTooltipConfig, @Inject(LOCALE_ID) private locale: string, @@ -52,11 +54,7 @@ export class AppComponent implements OnInit { ngOnInit() { this.router.events.subscribe((val) => { if (val instanceof NavigationEnd) { - let domain = 'mempool.space'; - if (this.stateService.env.BASE_MODULE === 'liquid') { - domain = 'liquid.network'; - } - this.link.setAttribute('href', 'https://' + domain + this.location.path()); + this.seoService.updateCanonical(this.location.path()); } }); } diff --git a/frontend/src/app/components/asset/asset.component.scss b/frontend/src/app/components/asset/asset.component.scss index 6f8bc0915..56b1d6258 100644 --- a/frontend/src/app/components/asset/asset.component.scss +++ b/frontend/src/app/components/asset/asset.component.scss @@ -1,5 +1,5 @@ .qr-wrapper { - background-color: var(--fg); + background-color: #fff; padding: 10px; padding-bottom: 5px; display: inline-block; diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.html b/frontend/src/app/components/balance-widget/balance-widget.component.html new file mode 100644 index 000000000..4923a2c06 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.html @@ -0,0 +1,59 @@ +
+
+
+
+
BTC Holdings
+
+ {{ ((addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
Change (7d)
+
+ {{ delta7d > 0 ? '+' : ''}}{{ ((delta7d) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
Change (30d)
+
+ {{ delta30d > 0 ? '+' : ''}}{{ ((delta30d) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
+
+ + +
+
+
BTC Holdings
+
+
+
+
+
+
+
Change (7d)
+
+
+
+
+
+
+
Change (30d)
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.scss b/frontend/src/app/components/balance-widget/balance-widget.component.scss new file mode 100644 index 000000000..a2f803c79 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.scss @@ -0,0 +1,160 @@ +.balance-container { + display: flex; + flex-direction: row; + justify-content: space-around; + height: 76px; + .shared-block { + color: var(--transparent-fg); + font-size: 12px; + } + .item { + padding: 0 5px; + width: 100%; + max-width: 150px; + &:last-child { + display: none; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + } + .card-text { + font-size: 22px; + margin-top: -9px; + position: relative; + } +} + + +.balance-skeleton { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { + flex-direction: row; + } + .item { + min-width: 120px; + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; + } + &:last-child{ + display: none; + @media (min-width: 485px) { + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + &:last-child { + margin-bottom: 0; + } + } + .card-text { + .skeleton-loader { + width: 100%; + display: block; + &:first-child { + margin: 14px auto 0; + max-width: 80px; + } + &:last-child { + margin: 10px auto 0; + max-width: 120px; + } + } + } +} + +.card { + background-color: var(--bg); + height: 126px; +} + +.card-title { + color: var(--title-fg); + font-size: 1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress { + display: inline-flex; + width: 100%; + background-color: var(--secondary); + height: 1.1rem; + max-width: 180px; +} + +.skeleton-loader { + max-width: 100%; +} + +.more-padding { + padding: 24px 20px; +} + +.small-bar { + height: 8px; + top: -4px; + max-width: 120px; +} + +.loading-container { + min-height: 76px; +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 24px 20px; + } +} + +.retarget-sign { + margin-right: -3px; + font-size: 14px; + top: -2px; + position: relative; +} + +.previous-retarget-sign { + margin-right: -2px; + font-size: 10px; +} + +.symbol { + font-size: 13px; + white-space: nowrap; +} \ No newline at end of file diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.ts b/frontend/src/app/components/balance-widget/balance-widget.component.ts new file mode 100644 index 000000000..8e1d3f442 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.ts @@ -0,0 +1,72 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { Observable, catchError, of } from 'rxjs'; + +@Component({ + selector: 'app-balance-widget', + templateUrl: './balance-widget.component.html', + styleUrls: ['./balance-widget.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BalanceWidgetComponent implements OnInit, OnChanges { + @Input() address: string; + @Input() addressInfo: Address; + @Input() addressSummary$: Observable | null; + @Input() isPubkey: boolean = false; + + isLoading: boolean = true; + error: any; + + delta7d: number = 0; + delta30d: number = 0; + + constructor( + public stateService: StateService, + private electrsApiService: ElectrsApiService, + private cd: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + + } + + ngOnChanges(changes: SimpleChanges): void { + this.isLoading = true; + if (!this.address || !this.addressInfo) { + return; + } + (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }), + )).subscribe(addressSummary => { + if (addressSummary) { + this.error = null; + this.calculateStats(addressSummary); + } + this.isLoading = false; + this.cd.markForCheck(); + }); + } + + calculateStats(summary: AddressTxSummary[]): void { + let weekTotal = 0; + let monthTotal = 0; + + const weekAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (7 * 24 * 60 * 60 * 1000)).getTime()) / 1000; + const monthAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (30 * 24 * 60 * 60 * 1000)).getTime()) / 1000; + for (let i = 0; i < summary.length && summary[i].time >= monthAgo; i++) { + monthTotal += summary[i].value; + if (summary[i].time >= weekAgo) { + weekTotal += summary[i].value; + } + } + this.delta7d = weekTotal; + this.delta30d = monthTotal; + } +} diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss index ed531e63d..5d8c286d3 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss index b73d55685..ec3aeadc8 100644 --- a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; diff --git a/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.html b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.html new file mode 100644 index 000000000..ee1d78f35 --- /dev/null +++ b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.html @@ -0,0 +1,55 @@ + + +
+
+
+ Block Fees Vs Subsidy + +
+ +
+
+ + + + + + + + + + +
+
+
+ +
+
+
+
+
+ +
diff --git a/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.scss b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.scss new file mode 100644 index 000000000..645605751 --- /dev/null +++ b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.scss @@ -0,0 +1,66 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } + @media (min-width: 992px) { + height: 40px; + } +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + display: flex; + flex-direction: column; + padding: 0px 15px; + width: 100%; + height: calc(100vh - 225px); + min-height: 400px; + @media (min-width: 992px) { + height: calc(100vh - 150px); + } +} + +.chart { + display: flex; + flex: 1; + width: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} +.chart-widget { + width: 100%; + height: 100%; + max-height: 270px; +} + +.disabled { + pointer-events: none; + opacity: 0.5; +} diff --git a/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts new file mode 100644 index 000000000..88d27033f --- /dev/null +++ b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts @@ -0,0 +1,510 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Inject, Input, LOCALE_ID, NgZone, OnInit } from '@angular/core'; +import { EChartsOption } from '../../graphs/echarts'; +import { Observable } from 'rxjs'; +import { catchError, map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { ApiService } from '../../services/api.service'; +import { SeoService } from '../../services/seo.service'; +import { formatNumber } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { download, formatterXAxis } from '../../shared/graphs.utils'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe'; +import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe'; +import { StateService } from '../../services/state.service'; +import { MiningService } from '../../services/mining.service'; +import { StorageService } from '../../services/storage.service'; +import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; + +@Component({ + selector: 'app-block-fees-subsidy-graph', + templateUrl: './block-fees-subsidy-graph.component.html', + styleUrls: ['./block-fees-subsidy-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BlockFeesSubsidyGraphComponent implements OnInit { + @Input() right: number | string = 45; + @Input() left: number | string = 75; + + miningWindowPreference: string; + radioGroupForm: UntypedFormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + statsObservable$: Observable; + data: any; + subsidies: { [key: number]: number } = {}; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + showFiat = false; + updateZoom = false; + zoomSpan = 100; + zoomTimeSpan = ''; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private apiService: ApiService, + private formBuilder: UntypedFormBuilder, + public stateService: StateService, + private storageService: StorageService, + private miningService: MiningService, + private route: ActivatedRoute, + private router: Router, + private zone: NgZone, + private fiatShortenerPipe: FiatShortenerPipe, + private fiatCurrencyPipe: FiatCurrencyPipe, + private cd: ChangeDetectorRef, + ) { + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); + this.radioGroupForm.controls.dateSpan.setValue('1y'); + + this.subsidies = this.initSubsidies(); + } + + ngOnInit(): void { + this.seoService.setTitle($localize`:@@mining.block-fees-subsidy:Block Fees Vs Subsidy`); + this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fees-subsidy:See the mining fees earned per Bitcoin block compared to the Bitcoin block subsidy, visualized in BTC and USD over time.`); + + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + this.route + .fragment + .subscribe((fragment) => { + if (['24h', '3d', '1w', '1m', '3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) { + this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); + } + }); + + this.statsObservable$ = this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith(this.radioGroupForm.controls.dateSpan.value), + switchMap((timespan) => { + this.isLoading = true; + this.storageService.setValue('miningWindowPreference', timespan); + this.timespan = timespan; + this.zoomTimeSpan = timespan; + this.isLoading = true; + return this.apiService.getHistoricalBlockFees$(timespan) + .pipe( + tap((response) => { + this.data = { + timestamp: response.body.map(val => val.timestamp * 1000), + blockHeight: response.body.map(val => val.avgHeight), + blockFees: response.body.map(val => val.avgFees / 100_000_000), + blockFeesFiat: response.body.filter(val => val['USD'] > 0).map(val => val.avgFees / 100_000_000 * val['USD']), + blockSubsidy: response.body.map(val => this.subsidies[Math.floor(Math.min(val.avgHeight / 210000, 33))] / 100_000_000), + blockSubsidyFiat: response.body.filter(val => val['USD'] > 0).map(val => this.subsidies[Math.floor(Math.min(val.avgHeight / 210000, 33))] / 100_000_000 * val['USD']), + }; + + this.prepareChartOptions(); + this.isLoading = false; + }), + map((response) => { + return { + blockCount: parseInt(response.headers.get('x-total-count'), 10), + }; + }), + ); + }), + share() + ); + } + + prepareChartOptions() { + let title: object; + if (this.data.blockFees.length === 0) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: $localize`:@@23555386d8af1ff73f297e89dd4af3f4689fb9dd:Indexing blocks`, + left: 'center', + top: 'center' + }; + } + + this.chartOptions = { + title: title, + color: [ + '#ff9f00', + '#0aab2f', + ], + animation: false, + grid: { + top: 80, + bottom: 80, + right: this.right, + left: this.left, + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'color-mix(in srgb, var(--active-bg) 95%, transparent)', + borderRadius: 4, + shadowColor: 'color-mix(in srgb, var(--active-bg) 95%, transparent)', + textStyle: { + color: 'var(--tooltip-grey)', + align: 'left', + }, + borderColor: 'var(--active-bg)', + formatter: function (data) { + if (data.length <= 0) { + return ''; + } + let tooltip = `${formatterXAxis(this.locale, this.zoomTimeSpan, parseInt(this.data.timestamp[data[0].dataIndex], 10))}
`; + for (let i = data.length - 1; i >= 0; i--) { + const tick = data[i]; + if (!this.showFiat) tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data, this.locale, '1.0-3')} BTC
`; + else tooltip += `${tick.marker} ${tick.seriesName}: ${this.fiatCurrencyPipe.transform(tick.data, null, 'USD') }
`; + } + if (!this.showFiat) tooltip += `
${formatNumber(data.reduce((acc, val) => acc + val.data, 0), this.locale, '1.0-3')} BTC
`; + else tooltip += `
${this.fiatCurrencyPipe.transform(data.reduce((acc, val) => acc + val.data, 0), null, 'USD')}
`; + if (['24h', '3d'].includes(this.zoomTimeSpan)) { + tooltip += `` + $localize`At block ${data[0].axisValue}` + ``; + } else { + tooltip += `` + $localize`Around block ${data[0].axisValue}` + ``; + } + return tooltip; + }.bind(this) + }, + xAxis: this.data.blockFees.length === 0 ? undefined : [ + { + type: 'category', + data: this.data.blockHeight, + show: false, + axisLabel: { + hideOverlap: true, + } + }, + { + type: 'category', + data: this.data.timestamp, + show: true, + position: 'bottom', + axisLabel: { + color: 'var(--grey)', + formatter: (val) => { + return formatterXAxis(this.locale, this.timespan, parseInt(val, 10)); + } + }, + axisTick: { + show: false, + }, + axisLine: { + show: false, + }, + splitLine: { + show: false, + }, + } + ], + legend: this.data.blockFees.length === 0 ? undefined : { + data: [ + { + name: 'Subsidy', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Fees', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Subsidy (USD)', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Fees (USD)', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + ], + selected: { + 'Subsidy (USD)': this.showFiat, + 'Fees (USD)': this.showFiat, + 'Subsidy': !this.showFiat, + 'Fees': !this.showFiat, + }, + }, + yAxis: this.data.blockFees.length === 0 ? undefined : [ + { + type: 'value', + axisLabel: { + color: 'var(--grey)', + formatter: (val) => { + return `${val} BTC`; + } + }, + min: 0, + splitLine: { + lineStyle: { + type: 'dotted', + color: 'var(--transparent-fg)', + opacity: 0.25, + } + }, + }, + { + type: 'value', + position: 'right', + axisLabel: { + color: 'var(--grey)', + formatter: function(val) { + return this.fiatShortenerPipe.transform(val, null, 'USD'); + }.bind(this) + }, + splitLine: { + show: false, + }, + }, + ], + series: this.data.blockFees.length === 0 ? undefined : [ + { + name: 'Subsidy', + yAxisIndex: 0, + type: 'bar', + stack: 'total', + data: this.data.blockSubsidy, + }, + { + name: 'Fees', + yAxisIndex: 0, + type: 'bar', + stack: 'total', + data: this.data.blockFees, + }, + { + name: 'Subsidy (USD)', + yAxisIndex: 1, + type: 'bar', + stack: 'total', + data: this.data.blockSubsidyFiat, + }, + { + name: 'Fees (USD)', + yAxisIndex: 1, + type: 'bar', + stack: 'total', + data: this.data.blockFeesFiat, + }, + ], + dataZoom: this.data.blockFees.length === 0 ? undefined : [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 1, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: 20, + right: 15, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + }, + }], + }; + } + + onChartInit(ec) { + this.chartInstance = ec; + + this.chartInstance.on('legendselectchanged', (params) => { + const isFiat = params.name.includes('USD'); + if (isFiat === this.showFiat) return; + + const isActivation = params.selected[params.name]; + if (isFiat === isActivation) { + this.showFiat = true; + this.chartInstance.dispatchAction({ type: 'legendUnSelect', name: 'Subsidy' }); + this.chartInstance.dispatchAction({ type: 'legendUnSelect', name: 'Fees' }); + this.chartInstance.dispatchAction({ type: 'legendSelect', name: 'Subsidy (USD)' }); + this.chartInstance.dispatchAction({ type: 'legendSelect', name: 'Fees (USD)' }); + } else { + this.showFiat = false; + this.chartInstance.dispatchAction({ type: 'legendSelect', name: 'Subsidy' }); + this.chartInstance.dispatchAction({ type: 'legendSelect', name: 'Fees' }); + this.chartInstance.dispatchAction({ type: 'legendUnSelect', name: 'Subsidy (USD)' }); + this.chartInstance.dispatchAction({ type: 'legendUnSelect', name: 'Fees (USD)' }); + } + }); + + this.chartInstance.on('datazoom', (params) => { + if (params.silent || this.isLoading || ['24h', '3d'].includes(this.timespan)) { + return; + } + this.updateZoom = true; + }); + + this.chartInstance.on('click', (e) => { + this.zone.run(() => { + if (['24h', '3d'].includes(this.zoomTimeSpan)) { + const url = new RelativeUrlPipe(this.stateService).transform(`/block/${e.name}`); + if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) { + window.open(url); + } else { + this.router.navigate([url]); + } + } + }); + }); + } + + @HostListener('document:pointerup', ['$event']) + onPointerUp(event: PointerEvent) { + if (this.updateZoom) { + this.onZoom(); + this.updateZoom = false; + } + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + + initSubsidies(): { [key: number]: number } { + let blockReward = 50 * 100_000_000; + const subsidies = {}; + for (let i = 0; i <= 33; i++) { + subsidies[i] = blockReward; + blockReward = Math.floor(blockReward / 2); + } + return subsidies; + } + + onZoom() { + const option = this.chartInstance.getOption(); + const timestamps = option.xAxis[1].data; + const startTimestamp = timestamps[option.dataZoom[0].startValue]; + const endTimestamp = timestamps[option.dataZoom[0].endValue]; + + this.isLoading = true; + this.cd.detectChanges(); + + const subscription = this.apiService.getBlockFeesFromTimespan$(Math.floor(startTimestamp / 1000), Math.floor(endTimestamp / 1000)) + .pipe( + tap((response) => { + const startIndex = option.dataZoom[0].startValue; + const endIndex = option.dataZoom[0].endValue; + + // Update series with more granular data + const lengthBefore = this.data.timestamp.length; + this.data.timestamp.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.timestamp * 1000)); + this.data.blockHeight.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.avgHeight)); + this.data.blockFees.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.avgFees / 100_000_000)); + this.data.blockFeesFiat.splice(startIndex, endIndex - startIndex, ...response.body.filter(val => val['USD'] > 0).map(val => val.avgFees / 100_000_000 * val['USD'])); + this.data.blockSubsidy.splice(startIndex, endIndex - startIndex, ...response.body.map(val => this.subsidies[Math.floor(Math.min(val.avgHeight / 210000, 33))] / 100_000_000)); + this.data.blockSubsidyFiat.splice(startIndex, endIndex - startIndex, ...response.body.filter(val => val['USD'] > 0).map(val => this.subsidies[Math.floor(Math.min(val.avgHeight / 210000, 33))] / 100_000_000 * val['USD'])); + option.series[0].data = this.data.blockSubsidy; + option.series[1].data = this.data.blockFees; + option.series[2].data = this.data.blockSubsidyFiat; + option.series[3].data = this.data.blockFeesFiat; + option.xAxis[0].data = this.data.blockHeight; + option.xAxis[1].data = this.data.timestamp; + this.chartInstance.setOption(option, true); + const lengthAfter = this.data.timestamp.length; + + // Update the zoom to keep the same range after the update + this.chartInstance.dispatchAction({ + type: 'dataZoom', + startValue: startIndex, + endValue: endIndex + lengthAfter - lengthBefore, + silent: true, + }); + + // Update the chart + const newOption = this.chartInstance.getOption(); + this.zoomSpan = newOption.dataZoom[0].end - newOption.dataZoom[0].start; + this.zoomTimeSpan = this.getTimeRangeFromTimespan(Math.floor(this.data.timestamp[newOption.dataZoom[0].startValue] / 1000), Math.floor(this.data.timestamp[newOption.dataZoom[0].endValue] / 1000)); + this.isLoading = false; + }), + catchError(() => { + const newOption = this.chartInstance.getOption(); + this.zoomSpan = newOption.dataZoom[0].end - newOption.dataZoom[0].start; + this.zoomTimeSpan = this.getTimeRangeFromTimespan(Math.floor(this.data.timestamp[newOption.dataZoom[0].startValue] / 1000), Math.floor(this.data.timestamp[newOption.dataZoom[0].endValue] / 1000)); + this.isLoading = false; + this.cd.detectChanges(); + return []; + }) + ).subscribe(() => { + subscription.unsubscribe(); + this.cd.detectChanges(); + }); + } + + getTimeRangeFromTimespan(from: number, to: number): string { + const timespan = to - from; + switch (true) { + case timespan >= 3600 * 24 * 365 * 4: return 'all'; + case timespan >= 3600 * 24 * 365 * 3: return '4y'; + case timespan >= 3600 * 24 * 365 * 2: return '3y'; + case timespan >= 3600 * 24 * 365: return '2y'; + case timespan >= 3600 * 24 * 30 * 6: return '1y'; + case timespan >= 3600 * 24 * 30 * 3: return '6m'; + case timespan >= 3600 * 24 * 30: return '3m'; + case timespan >= 3600 * 24 * 7: return '1m'; + case timespan >= 3600 * 24 * 3: return '1w'; + case timespan >= 3600 * 24: return '3d'; + default: return '24h'; + } + } + + onSaveChart() { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + const now = new Date(); + // @ts-ignore + this.chartOptions.grid.bottom = 40; + this.chartOptions.backgroundColor = 'var(--active-bg)'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + excludeComponents: ['dataZoom'], + }), `block-fees-subsidy-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } + +} diff --git a/frontend/src/app/components/block-health-graph/block-health-graph.component.scss b/frontend/src/app/components/block-health-graph/block-health-graph.component.scss index 7b8154bae..992906e5c 100644 --- a/frontend/src/app/components/block-health-graph/block-health-graph.component.scss +++ b/frontend/src/app/components/block-health-graph/block-health-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index 3fee3f901..6231ba70d 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -81,6 +81,20 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On tooltipPosition: Position; readyNextFrame = false; + lastUpdate: number = 0; + pendingUpdate: { + count: number, + add: { [txid: string]: TransactionStripped }, + remove: { [txid: string]: string }, + change: { [txid: string]: { txid: string, rate: number | undefined, acc: boolean | undefined } }, + direction?: string, + } = { + count: 0, + add: {}, + remove: {}, + change: {}, + direction: 'left', + }; searchText: string; searchSubscription: Subscription; @@ -176,6 +190,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On destroy(): void { if (this.scene) { this.scene.destroy(); + this.clearUpdateQueue(); this.start(); } } @@ -188,6 +203,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } this.filtersAvailable = filtersAvailable; if (this.scene) { + this.clearUpdateQueue(); this.scene.setup(transactions); this.readyNextFrame = true; this.start(); @@ -197,6 +213,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On enter(transactions: TransactionStripped[], direction: string): void { if (this.scene) { + this.clearUpdateQueue(); this.scene.enter(transactions, direction); this.start(); this.updateSearchHighlight(); @@ -205,6 +222,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On exit(direction: string): void { if (this.scene) { + this.clearUpdateQueue(); this.scene.exit(direction); this.start(); this.updateSearchHighlight(); @@ -213,13 +231,67 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On replace(transactions: TransactionStripped[], direction: string, sort: boolean = true, startTime?: number): void { if (this.scene) { + this.clearUpdateQueue(); this.scene.replace(transactions || [], direction, sort, startTime); this.start(); this.updateSearchHighlight(); } } + // collates deferred updates into a set of consistent pending changes + queueUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void { + for (const tx of add) { + this.pendingUpdate.add[tx.txid] = tx; + delete this.pendingUpdate.remove[tx.txid]; + delete this.pendingUpdate.change[tx.txid]; + } + for (const txid of remove) { + delete this.pendingUpdate.add[txid]; + this.pendingUpdate.remove[txid] = txid; + delete this.pendingUpdate.change[txid]; + } + for (const tx of change) { + if (this.pendingUpdate.add[tx.txid]) { + this.pendingUpdate.add[tx.txid].rate = tx.rate; + this.pendingUpdate.add[tx.txid].acc = tx.acc; + } else { + this.pendingUpdate.change[tx.txid] = tx; + } + } + this.pendingUpdate.direction = direction; + this.pendingUpdate.count++; + } + + deferredUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void { + this.queueUpdate(add, remove, change, direction); + this.applyQueuedUpdates(); + } + + applyQueuedUpdates(): void { + if (this.pendingUpdate.count && performance.now() > (this.lastUpdate + this.animationDuration)) { + this.applyUpdate(Object.values(this.pendingUpdate.add), Object.values(this.pendingUpdate.remove), Object.values(this.pendingUpdate.change), this.pendingUpdate.direction); + this.clearUpdateQueue(); + } + } + + clearUpdateQueue(): void { + this.pendingUpdate = { + count: 0, + add: {}, + remove: {}, + change: {}, + }; + this.lastUpdate = performance.now(); + } + update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { + // merge any pending changes into this update + this.queueUpdate(add, remove, change); + this.applyUpdate(Object.values(this.pendingUpdate.add), Object.values(this.pendingUpdate.remove), Object.values(this.pendingUpdate.change), direction, resetLayout); + this.clearUpdateQueue(); + } + + applyUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { if (this.scene) { add = add.filter(tx => !this.scene.txs[tx.txid]); remove = remove.filter(txid => this.scene.txs[txid]); @@ -230,6 +302,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } this.scene.update(add, remove, change, direction, resetLayout); this.start(); + this.lastUpdate = performance.now(); this.updateSearchHighlight(); } } @@ -370,6 +443,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (!now) { now = performance.now(); } + this.applyQueuedUpdates(); // skip re-render if there's no change to the scene if (this.scene && this.gl) { /* SET UP SHADER UNIFORMS */ @@ -577,13 +651,13 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On getFilterColorFunction(flags: bigint, gradient: 'fee' | 'age'): ((tx: TxView) => Color) { return (tx: TxView) => { if ((this.filterMode === 'and' && (tx.bigintFlags & flags) === flags) || (this.filterMode === 'or' && (flags === 0n || (tx.bigintFlags & flags) > 0n))) { - if (this.themeService.theme !== 'contrast') { + if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') { return (gradient === 'age') ? ageColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)) : defaultColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)); } else { return (gradient === 'age') ? ageColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000)) : contrastColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000)); } } else { - if (this.themeService.theme !== 'contrast') { + if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') { return (gradient === 'age') ? { r: 1, g: 1, b: 1, a: 0.05 } : defaultColorFunction( tx, defaultColors.unmatchedfee, diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index bef907a7a..c59fcb7d4 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -13,7 +13,7 @@ export default class BlockScene { theme: ThemeService; orientation: string; flip: boolean; - animationDuration: number = 900; + animationDuration: number = 1000; configAnimationOffset: number | null; animationOffset: number; highlightingEnabled: boolean; @@ -69,7 +69,7 @@ export default class BlockScene { } setColorFunction(colorFunction: ((tx: TxView) => Color) | null): void { - this.theme.theme === 'contrast' ? this.getColor = colorFunction || contrastColorFunction : this.getColor = colorFunction || defaultColorFunction; + this.theme.theme === 'contrast' || this.theme.theme === 'bukele' ? this.getColor = colorFunction || contrastColorFunction : this.getColor = colorFunction || defaultColorFunction; this.updateAllColors(); } @@ -179,7 +179,7 @@ export default class BlockScene { removed.forEach(tx => { tx.destroy(); }); - }, 1000); + }, (startTime - performance.now()) + this.animationDuration + 1000); if (resetLayout) { add.forEach(tx => { @@ -239,14 +239,14 @@ export default class BlockScene { { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } ): void { - this.animationDuration = animationDuration || 1000; + this.animationDuration = animationDuration || this.animationDuration || 1000; this.configAnimationOffset = animationOffset; this.animationOffset = this.configAnimationOffset == null ? (this.width * 1.4) : this.configAnimationOffset; this.orientation = orientation; this.flip = flip; this.vertexArray = vertexArray; this.highlightingEnabled = highlighting; - theme.theme === 'contrast' ? this.getColor = colorFunction || contrastColorFunction : this.getColor = colorFunction || defaultColorFunction; + theme.theme === 'contrast' || theme.theme === 'bukele' ? this.getColor = colorFunction || contrastColorFunction : this.getColor = colorFunction || defaultColorFunction; this.theme = theme; this.scene = { diff --git a/frontend/src/app/components/block-overview-graph/utils.ts b/frontend/src/app/components/block-overview-graph/utils.ts index ec6181853..9a6d9da43 100644 --- a/frontend/src/app/components/block-overview-graph/utils.ts +++ b/frontend/src/app/components/block-overview-graph/utils.ts @@ -177,7 +177,7 @@ export function ageColorFunction( return auditColors.accelerated; } - const color = theme !== 'contrast' ? defaultColorFunction(tx, colors, auditColors, relativeTime) : contrastColorFunction(tx, colors, auditColors, relativeTime); + const color = theme !== 'contrast' && theme !== 'bukele' ? defaultColorFunction(tx, colors, auditColors, relativeTime) : contrastColorFunction(tx, colors, auditColors, relativeTime); const ageLevel = (!tx.time ? 0 : (0.8 * Math.tanh((1 / 15) * Math.log2((Math.max(1, 0.6 * ((relativeTime - tx.time) - 60))))))); return { diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss index 507d4c18d..28708506b 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.scss @@ -1,6 +1,6 @@ .block-overview-tooltip { position: absolute; - background: rgba(#11131f, 0.95); + background: color-mix(in srgb, var(--active-bg) 95%, transparent); border-radius: 4px; box-shadow: 1px 1px 10px rgba(0,0,0,0.5); color: var(--tooltip-grey); @@ -30,7 +30,7 @@ th, td { } .badge.badge-accelerated { - background-color: var(--tertiary); + background-color: #653b9c; box-shadow: #ad7de57f 0px 0px 12px -2px; color: white; animation: acceleratePulse 1s infinite; @@ -71,7 +71,7 @@ th, td { } @keyframes acceleratePulse { - 0% { background-color: var(--tertiary); box-shadow: #ad7de57f 0px 0px 12px -2px; } + 0% { background-color: #653b9c; box-shadow: #ad7de57f 0px 0px 12px -2px; } 50% { background-color: #8457bb; box-shadow: #ad7de5 0px 0px 18px -2px;} - 100% { background-color: var(--tertiary); box-shadow: #ad7de57f 0px 0px 12px -2px; } + 100% { background-color: #653b9c; box-shadow: #ad7de57f 0px 0px 12px -2px; } } \ No newline at end of file diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss index 7b8154bae..992906e5c 100644 --- a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; diff --git a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss index 7b8154bae..992906e5c 100644 --- a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss +++ b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss @@ -11,7 +11,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; diff --git a/frontend/src/app/components/block/block-preview.component.ts b/frontend/src/app/components/block/block-preview.component.ts index 91dfef8c2..72da96818 100644 --- a/frontend/src/app/components/block/block-preview.component.ts +++ b/frontend/src/app/components/block/block-preview.component.ts @@ -136,7 +136,12 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { return of(transactions); }) ), - this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) : of([]) + this.stateService.env.ACCELERATOR === true && block.height > 819500 + ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) + .pipe(catchError(() => { + return of([]); + })) + : of([]) ]); } ), diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 98ac1b452..9b0dc0d05 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -345,7 +345,12 @@ export class BlockComponent implements OnInit, OnDestroy { return of(null); }) ), - this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) : of([]) + this.stateService.env.ACCELERATOR === true && block.height > 819500 + ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) + .pipe(catchError(() => { + return of([]); + })) + : of([]) ]); }) ) diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss index 269fdab42..b11a35513 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss @@ -63,7 +63,7 @@ .fee-span { font-size: 11px; margin-bottom: 5px; - color: #fff000; + color: var(--yellow); } .transaction-count { @@ -130,7 +130,7 @@ height: 0; border-left: calc(var(--block-size) * 0.3) solid transparent; border-right: calc(var(--block-size) * 0.3) solid transparent; - border-bottom: calc(var(--block-size) * 0.3) solid #FFF; + border-bottom: calc(var(--block-size) * 0.3) solid var(--fg); } .flashing { diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts index 35499f162..1a7598079 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -70,6 +70,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { liquid: ['var(--liquid)', 'var(--testnet-alt)'], 'liquidtestnet': ['var(--liquidtestnet)', 'var(--liquidtestnet-alt)'], testnet: ['var(--testnet)', 'var(--testnet-alt)'], + testnet4: ['var(--testnet)', 'var(--testnet-alt)'], signet: ['var(--signet)', 'var(--signet-alt)'], }; @@ -349,7 +350,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { return { left: addLeft + this.blockOffset * index + 'px', background: `repeating-linear-gradient( - #2d3348, + var(--secondary), var(--secondary) ${greenBackgroundHeight}%, ${this.gradientColors[this.network][0]} ${Math.max(greenBackgroundHeight, 0)}%, ${this.gradientColors[this.network][1]} 100% @@ -361,7 +362,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { convertStyleForLoadingBlock(style) { return { ...style, - background: "#2d3348", + background: "var(--secondary)", }; } @@ -370,7 +371,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { return { left: addLeft + (this.blockOffset * index) + 'px', - background: "#2d3348", + background: "var(--secondary)", }; } diff --git a/frontend/src/app/components/blockchain/blockchain.component.scss b/frontend/src/app/components/blockchain/blockchain.component.scss index b0a589a04..700f57a27 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.scss +++ b/frontend/src/app/components/blockchain/blockchain.component.scss @@ -54,7 +54,7 @@ } .time-toggle { - color: white; + color: var(--fg); font-size: 0.8rem; position: absolute; bottom: -1.8em; @@ -68,7 +68,7 @@ } .block-display-toggle { - color: white; + color: var(--fg); font-size: 0.8rem; position: absolute; bottom: 15.8em; diff --git a/frontend/src/app/components/blockchain/blockchain.component.ts b/frontend/src/app/components/blockchain/blockchain.component.ts index 60dc22e12..d70e788a2 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.ts +++ b/frontend/src/app/components/blockchain/blockchain.component.ts @@ -55,7 +55,7 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges { firstValueFrom(this.stateService.chainTip$).then(() => { this.loadingTip = false; }); - this.blockDisplayMode = this.StorageService.getValue('block-display-mode-preference') as 'size' | 'fees' || 'size'; + this.blockDisplayMode = this.StorageService.getValue('block-display-mode-preference') as 'size' | 'fees' || 'fees'; } ngOnDestroy(): void { diff --git a/frontend/src/app/components/clock/clock.component.ts b/frontend/src/app/components/clock/clock.component.ts index 94ff3e810..4a9b19e78 100644 --- a/frontend/src/app/components/clock/clock.component.ts +++ b/frontend/src/app/components/clock/clock.component.ts @@ -32,11 +32,12 @@ export class ClockComponent implements OnInit { limitHeight: number; gradientColors = { - '': ['#9339f4', '#105fb0'], - liquid: ['#116761', '#183550'], - 'liquidtestnet': ['#494a4a', '#272e46'], - testnet: ['#1d486f', '#183550'], - signet: ['#6f1d5d', '#471850'], + '': ['var(--mainnet-alt)', 'var(--primary)'], + liquid: ['var(--liquid)', 'var(--testnet-alt)'], + 'liquidtestnet': ['var(--liquidtestnet)', 'var(--liquidtestnet-alt)'], + testnet: ['var(--testnet)', 'var(--testnet-alt)'], + testnet4: ['var(--testnet)', 'var(--testnet-alt)'], + signet: ['var(--signet)', 'var(--signet-alt)'], }; constructor( @@ -99,8 +100,8 @@ export class ClockComponent implements OnInit { return { background: `repeating-linear-gradient( - #2d3348, - #2d3348 ${greenBackgroundHeight}%, + var(--secondary), + var(--secondary) ${greenBackgroundHeight}%, ${this.gradientColors[''][0]} ${Math.max(greenBackgroundHeight, 0)}%, ${this.gradientColors[''][1]} 100% )`, diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html new file mode 100644 index 000000000..6e2db2165 --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html @@ -0,0 +1,286 @@ + +
+
+ @for (widget of widgets; track widget.component) { + @switch (widget.component) { + @case ('fees') { +
+
Transaction Fees
+
+
+ +
+
+
+ } + @case ('difficulty') { +
+ +
+ } + @case ('goggles') { +
+
+ +
+
+ } + @case ('incoming') { +
+
+
+ +
Incoming Transactions
+
+ +
+
+
+
+ +
+
+
Minimum fee
+
Purging
+

+ < +

+
+
+
Unconfirmed
+

+ {{ mempoolInfoData.value.memPoolInfo.size | number }} TXs +

+
+
+
Memory Usage
+
+
+
 
+
/
+
+
+
+
+
+ } + @case ('replacements') { +
+
+
+ +
Recent Replacements
+   + +
+ + + + + + + + + + + + + + + +
TXIDPrevious feeNew feeStatus
+ + + + + Mined + Full RBF + RBF +
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('blocks') { +
+
+
+ +
Recent Blocks
+   + +
+ + + + + + + + + + + + + + + +
HeightMinedTXsSize
{{ block.height }}{{ block.tx_count | number }} +
+
 
+
+
+
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('transactions') { +
+
+
+
Recent Transactions
+ + + + + + + + + + + + + + + +
TXIDAmount{{ currency }}Fee
+ + + + Confidential
+
 
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('balance') { +
+
Treasury
+ +
+ } + @case ('address') { + + } + @case ('addressTransactions') { + + } + @case ('twitter') { +
+
+
+ +
X Timeline
+   + +
+ @defer { + + } +
+
+
+ } + } + } +
+
+ + +
+
+ + + \ No newline at end of file diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss new file mode 100644 index 000000000..4a9ffe94a --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss @@ -0,0 +1,490 @@ +.dashboard-container { + text-align: center; + margin-top: 0.5rem; + .col { + margin-bottom: 1.5rem; + } +} + +.card { + background-color: var(--bg); + height: 100%; +} + +.card-title { + color: var(--title-fg); + font-size: 1rem; +} + +.info-block { + float: left; + width: 350px; + line-height: 25px; +} + +.progress { + display: inline-flex; + width: 100%; + background-color: var(--secondary); + height: 1.1rem; + max-width: 180px; +} + +.bg-warning { + background-color: #b58800 !important; +} + +.skeleton-loader { + max-width: 100%; +} + +.more-padding { + padding: 18px; +} + +.graph-card { + height: 100%; + @media (min-width: 768px) { + height: 415px; + } + @media (min-width: 992px) { + height: 510px; + } +} + +.mempool-info-data { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + &.lbtc-pegs-stats { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + margin: 0px auto 20px; + display: inline-block; + @media (min-width: 485px) { + margin: 0px auto 10px; + } + @media (min-width: 768px) { + margin: 0px auto 0px; + } + &:last-child { + margin: 0px auto 0px; + } + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + .bitcoin-color { + color: var(--orange); + } + } + .progress { + width: 90%; + @media (min-width: 768px) { + width: 100%; + } + } + } + .bar { + width: 93%; + margin: 0px 5px 20px; + @media (min-width: 485px) { + max-width: 200px; + margin: 0px auto 0px; + } + } + .skeleton-loader { + width: 100%; + max-width: 100px; + display: block; + margin: 18px auto 0; + } + .skeleton-loader-big { + max-width: 180px; + } +} + +.latest-transactions { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-satoshis { + display: none; + text-align: right; + @media (min-width: 576px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 1100px) { + display: table-cell; + } + } + .table-cell-fiat { + display: none; + text-align: right; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + .table-cell-fees { + text-align: right; + } +} +.skeleton-loader-transactions { + max-width: 250px; + position: relative; + top: 2px; + margin-bottom: -3px; + height: 18px; +} + +.lastest-blocks-table { + width: 100%; + text-align: left; + tr, td, th { + border: 0px; + padding-top: 0.65rem !important; + padding-bottom: 0.7rem !important; + } + .table-cell-height { + width: 15%; + } + .table-cell-mined { + width: 35%; + text-align: left; + } + .table-cell-transaction-count { + display: none; + text-align: right; + width: 20%; + display: table-cell; + } + .table-cell-size { + display: none; + text-align: center; + width: 30%; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } +} + +.lastest-replacements-table { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-txid { + width: 25%; + text-align: start; + } + .table-cell-old-fee { + width: 25%; + text-align: end; + + @media(max-width: 1080px) { + display: none; + } + } + .table-cell-new-fee { + width: 20%; + text-align: end; + } + .table-cell-badges { + width: 23%; + padding-right: 0; + padding-left: 5px; + text-align: end; + + .badge { + margin-left: 5px; + } + } +} + +.mempool-graph { + height: 255px; + @media (min-width: 768px) { + height: 285px; + } + @media (min-width: 992px) { + height: 370px; + } +} +.loadingGraphs{ + height: 250px; + display: grid; + place-items: center; +} + +.inc-tx-progress-bar { + max-width: 250px; + .progress-bar { + padding: 4px; + } +} + +.terms-of-service { + margin-top: 1rem; +} + +.small-bar { + height: 8px; + top: -4px; + max-width: 120px; +} + +.loading-container { + min-height: 76px; +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 22px 20px; + &.liquid { + height: 124.5px; + } + } + .less-padding { + padding: 20px 20px; + } +} + +.retarget-sign { + margin-right: -3px; + font-size: 14px; + top: -2px; + position: relative; +} + +.previous-retarget-sign { + margin-right: -2px; + font-size: 10px; +} + +.assetIcon { + width: 40px; + height: 40px; +} + +.asset-title { + text-align: left; + vertical-align: middle; +} + +.asset-icon { + width: 65px; + height: 65px; + vertical-align: middle; +} + +.circulating-amount { + text-align: right; + width: 100%; + vertical-align: middle; +} + +.clear-link { + color: white; +} + +.pool-name { + display: inline-block; + vertical-align: text-top; + padding-left: 10px; +} + +.title-link, .title-link:hover, .title-link:focus, .title-link:active { + display: block; + margin-bottom: 10px; + text-decoration: none; + color: inherit; +} + +.mempool-block-wrapper { + max-height: 410px; + max-width: 410px; + margin: auto; + + @media (min-width: 768px) { + max-height: 344px; + max-width: 344px; + } + @media (min-width: 992px) { + max-height: 410px; + max-width: 410px; + } +} + +.goggle-badge { + margin: 6px 5px 8px; + background: none; + border: solid 2px var(--primary); + cursor: pointer; + + &.active { + background: var(--primary); + } +} + +.btn-xs { + padding: 0.35rem 0.5rem; + font-size: 12px; +} + +.quick-filter { + margin-top: 5px; + margin-bottom: 6px; +} + +.card-liquid { + background-color: var(--bg); + height: 418px; + @media (min-width: 992px) { + height: 512px; + } + &.smaller { + height: 408px; + } +} + +.card-title-liquid { + padding-top: 20px; + margin-left: 10px; +} + +.in-progress-message { + position: relative; + color: #ffffff91; + margin-top: 20px; + text-align: center; + padding-bottom: 3px; + font-weight: 500; +} + +.stats-card { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: var(--title-fg); + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts new file mode 100644 index 000000000..fbaf7be74 --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts @@ -0,0 +1,378 @@ +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; +import { combineLatest, merge, Observable, of, Subject, Subscription } from 'rxjs'; +import { catchError, filter, map, scan, share, shareReplay, startWith, switchMap, tap } from 'rxjs/operators'; +import { BlockExtended, OptimizedMempoolStats, TransactionStripped } from '../../interfaces/node-api.interface'; +import { MempoolInfo, ReplacementInfo } from '../../interfaces/websocket.interface'; +import { ApiService } from '../../services/api.service'; +import { StateService } from '../../services/state.service'; +import { WebsocketService } from '../../services/websocket.service'; +import { SeoService } from '../../services/seo.service'; +import { ActiveFilter, FilterMode, GradientMode, toFlags } from '../../shared/filters.utils'; +import { detectWebGL } from '../../shared/graphs.utils'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; + +interface MempoolBlocksData { + blocks: number; + size: number; +} + +interface MempoolInfoData { + memPoolInfo: MempoolInfo; + vBytesPerSecond: number; + progressWidth: string; + progressColor: string; +} + +interface MempoolStatsData { + mempool: OptimizedMempoolStats[]; + weightPerSecond: any; +} + +@Component({ + selector: 'app-custom-dashboard', + templateUrl: './custom-dashboard.component.html', + styleUrls: ['./custom-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewInit { + network$: Observable; + mempoolBlocksData$: Observable; + mempoolInfoData$: Observable; + mempoolLoadingStatus$: Observable; + vBytesPerSecondLimit = 1667; + transactions$: Observable; + blocks$: Observable; + replacements$: Observable; + latestBlockHeight: number; + mempoolTransactionsWeightPerSecondData: any; + mempoolStats$: Observable; + transactionsWeightPerSecondOptions: any; + isLoadingWebSocket$: Observable; + isLoad: boolean = true; + filterSubscription: Subscription; + mempoolInfoSubscription: Subscription; + currencySubscription: Subscription; + currency: string; + incomingGraphHeight: number = 300; + graphHeight: number = 300; + webGlEnabled = true; + isMobile: boolean = window.innerWidth <= 767.98; + + widgets; + + addressSubscription: Subscription; + blockTxSubscription: Subscription; + addressSummary$: Observable; + address: Address; + + goggleResolution = 82; + goggleCycle: { index: number, name: string, mode: FilterMode, filters: string[], gradient: GradientMode }[] = [ + { index: 0, name: $localize`:@@dfc3c34e182ea73c5d784ff7c8135f087992dac1:All`, mode: 'and', filters: [], gradient: 'age' }, + { index: 1, name: $localize`Consolidation`, mode: 'and', filters: ['consolidation'], gradient: 'fee' }, + { index: 2, name: $localize`Coinjoin`, mode: 'and', filters: ['coinjoin'], gradient: 'fee' }, + { index: 3, name: $localize`Data`, mode: 'or', filters: ['inscription', 'fake_pubkey', 'op_return'], gradient: 'fee' }, + ]; + goggleFlags = 0n; + goggleMode: FilterMode = 'and'; + gradientMode: GradientMode = 'age'; + goggleIndex = 0; + + private destroy$ = new Subject(); + + constructor( + public stateService: StateService, + private apiService: ApiService, + private electrsApiService: ElectrsApiService, + private websocketService: WebsocketService, + private seoService: SeoService, + private cd: ChangeDetectorRef, + @Inject(PLATFORM_ID) private platformId: Object, + ) { + this.webGlEnabled = this.stateService.isBrowser && detectWebGL(); + this.widgets = this.stateService.env.customize?.dashboard.widgets || []; + } + + ngAfterViewInit(): void { + this.stateService.focusSearchInputDesktop(); + } + + ngOnDestroy(): void { + this.filterSubscription.unsubscribe(); + this.mempoolInfoSubscription.unsubscribe(); + this.currencySubscription.unsubscribe(); + this.websocketService.stopTrackRbfSummary(); + if (this.addressSubscription) { + this.addressSubscription.unsubscribe(); + this.websocketService.stopTrackingAddress(); + this.address = null; + } + this.destroy$.next(1); + this.destroy$.complete(); + } + + ngOnInit(): void { + this.onResize(); + this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; + this.seoService.resetTitle(); + this.seoService.resetDescription(); + this.websocketService.want(['blocks', 'stats', 'mempool-blocks', 'live-2h-chart']); + this.websocketService.startTrackRbfSummary(); + this.network$ = merge(of(''), this.stateService.networkChanged$); + this.mempoolLoadingStatus$ = this.stateService.loadingIndicators$ + .pipe( + map((indicators) => indicators.mempool !== undefined ? indicators.mempool : 100) + ); + + this.filterSubscription = this.stateService.activeGoggles$.subscribe((active: ActiveFilter) => { + const activeFilters = active.filters.sort().join(','); + for (const goggle of this.goggleCycle) { + if (goggle.mode === active.mode) { + const goggleFilters = goggle.filters.sort().join(','); + if (goggleFilters === activeFilters) { + this.goggleIndex = goggle.index; + this.goggleFlags = toFlags(goggle.filters); + this.goggleMode = goggle.mode; + this.gradientMode = active.gradient; + return; + } + } + } + this.goggleCycle.push({ + index: this.goggleCycle.length, + name: 'Custom', + mode: active.mode, + filters: active.filters, + gradient: active.gradient, + }); + this.goggleIndex = this.goggleCycle.length - 1; + this.goggleFlags = toFlags(active.filters); + this.goggleMode = active.mode; + }); + + this.mempoolInfoData$ = combineLatest([ + this.stateService.mempoolInfo$, + this.stateService.vbytesPerSecond$ + ]).pipe( + map(([mempoolInfo, vbytesPerSecond]) => { + const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100); + + let progressColor = 'bg-success'; + if (vbytesPerSecond > 1667) { + progressColor = 'bg-warning'; + } + if (vbytesPerSecond > 3000) { + progressColor = 'bg-danger'; + } + + const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100); + let mempoolSizeProgress = 'bg-danger'; + if (mempoolSizePercentage <= 50) { + mempoolSizeProgress = 'bg-success'; + } else if (mempoolSizePercentage <= 75) { + mempoolSizeProgress = 'bg-warning'; + } + + return { + memPoolInfo: mempoolInfo, + vBytesPerSecond: vbytesPerSecond, + progressWidth: percent + '%', + progressColor: progressColor, + mempoolSizeProgress: mempoolSizeProgress, + }; + }) + ); + + this.mempoolInfoSubscription = this.mempoolInfoData$.subscribe(); + + this.mempoolBlocksData$ = this.stateService.mempoolBlocks$ + .pipe( + map((mempoolBlocks) => { + const size = mempoolBlocks.map((m) => m.blockSize).reduce((a, b) => a + b, 0); + const vsize = mempoolBlocks.map((m) => m.blockVSize).reduce((a, b) => a + b, 0); + + return { + size: size, + blocks: Math.ceil(vsize / this.stateService.blockVSize) + }; + }) + ); + + this.transactions$ = this.stateService.transactions$; + + this.blocks$ = this.stateService.blocks$ + .pipe( + tap((blocks) => { + this.latestBlockHeight = blocks[0].height; + }), + switchMap((blocks) => { + if (this.stateService.env.MINING_DASHBOARD === true) { + for (const block of blocks) { + // @ts-ignore: Need to add an extra field for the template + block.extras.pool.logo = `/resources/mining-pools/` + + block.extras.pool.slug + '.svg'; + } + } + return of(blocks.slice(0, 6)); + }) + ); + + this.replacements$ = this.stateService.rbfLatestSummary$; + + this.mempoolStats$ = this.stateService.connectionState$ + .pipe( + filter((state) => state === 2), + switchMap(() => this.apiService.list2HStatistics$().pipe( + catchError((e) => { + return of(null); + }) + )), + switchMap((mempoolStats) => { + return merge( + this.stateService.live2Chart$ + .pipe( + scan((acc, stats) => { + const now = Date.now() / 1000; + const start = now - (2 * 60 * 60); + acc.unshift(stats); + acc = acc.filter(p => p.added >= start); + return acc; + }, (mempoolStats || [])) + ), + of(mempoolStats) + ); + }), + map((mempoolStats) => { + if (mempoolStats) { + return { + mempool: mempoolStats, + weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])), + }; + } else { + return null; + } + }), + shareReplay(1), + ); + + this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { + this.currency = fiat; + }); + + this.startAddressSubscription(); + } + + handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) { + mempoolStats.reverse(); + const labels = mempoolStats.map(stats => stats.added); + + return { + labels: labels, + series: [mempoolStats.map((stats) => [stats.added * 1000, stats.vbytes_per_second])], + }; + } + + trackByBlock(index: number, block: BlockExtended) { + return block.height; + } + + getArrayFromNumber(num: number): number[] { + return Array.from({ length: num }, (_, i) => i + 1); + } + + setFilter(index): void { + const selected = this.goggleCycle[index]; + this.stateService.activeGoggles$.next(selected); + } + + startAddressSubscription(): void { + if (this.stateService.env.customize && this.stateService.env.customize.dashboard.widgets.some(w => w.props?.address)) { + let addressString = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.address).props.address; + addressString = (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(addressString)) ? addressString.toLowerCase() : addressString; + + this.addressSubscription = ( + addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) + ? this.electrsApiService.getPubKeyAddress$(addressString) + : this.electrsApiService.getAddress$(addressString) + ).pipe( + catchError((err) => { + console.log(err); + return of(null); + }), + filter((address) => !!address), + ).subscribe((address: Address) => { + this.websocketService.startTrackAddress(address.address); + this.address = address; + this.cd.markForCheck(); + }); + + this.addressSummary$ = ( + addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) + ? this.electrsApiService.getScriptHashSummary$((addressString.length === 66 ? '21' : '41') + addressString + 'ac') + : this.electrsApiService.getAddressSummary$(addressString)).pipe( + catchError(e => { + return of(null); + }), + switchMap(initial => this.stateService.blockTransactions$.pipe( + startWith(null), + scan((summary, tx) => { + if (tx && !summary.some(t => t.txid === tx.txid)) { + let value = 0; + let funded = 0; + let fundedCount = 0; + let spent = 0; + let spentCount = 0; + for (const vout of tx.vout) { + if (vout.scriptpubkey_address === addressString) { + value += vout.value; + funded += vout.value; + fundedCount++; + } + } + for (const vin of tx.vin) { + if (vin.prevout?.scriptpubkey_address === addressString) { + value -= vin.prevout?.value; + spent += vin.prevout?.value; + spentCount++; + } + } + if (this.address && this.address.address === addressString) { + this.address.chain_stats.tx_count++; + this.address.chain_stats.funded_txo_sum += funded; + this.address.chain_stats.funded_txo_count += fundedCount; + this.address.chain_stats.spent_txo_sum += spent; + this.address.chain_stats.spent_txo_count += spentCount; + } + summary.unshift({ + txid: tx.txid, + time: tx.status?.block_time, + height: tx.status?.block_height, + value + }); + } + return summary; + }, initial) + )), + share(), + ); + } + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + if (window.innerWidth >= 992) { + this.incomingGraphHeight = 300; + this.goggleResolution = 82; + this.graphHeight = 400; + } else if (window.innerWidth >= 768) { + this.incomingGraphHeight = 215; + this.goggleResolution = 80; + this.graphHeight = 310; + } else { + this.incomingGraphHeight = 180; + this.goggleResolution = 86; + this.graphHeight = 310; + } + this.isMobile = window.innerWidth <= 767.98; + } +} diff --git a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss index 77f54f267..bd396928f 100644 --- a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss +++ b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.scss @@ -119,7 +119,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; diff --git a/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss b/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss index 5b4a8a02f..e4fd989af 100644 --- a/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss +++ b/frontend/src/app/components/difficulty/difficulty-tooltip.component.scss @@ -1,9 +1,9 @@ .difficulty-tooltip { position: fixed; - background: rgba(#11131f, 0.95); + background: color-mix(in srgb, var(--active-bg) 95%, transparent); border-radius: 4px; box-shadow: 1px 1px 10px rgba(0,0,0,0.5); - color: #b1b1b1; + color: var(--tooltip-grey); padding: 10px 15px; text-align: left; pointer-events: none; diff --git a/frontend/src/app/components/difficulty/difficulty.component.html b/frontend/src/app/components/difficulty/difficulty.component.html index c9b3d183b..e9bf36515 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.html +++ b/frontend/src/app/components/difficulty/difficulty.component.html @@ -15,8 +15,8 @@ - - + + diff --git a/frontend/src/app/components/difficulty/difficulty.component.scss b/frontend/src/app/components/difficulty/difficulty.component.scss index d10b800a8..8de7fae2c 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.scss +++ b/frontend/src/app/components/difficulty/difficulty.component.scss @@ -128,7 +128,8 @@ .main-title { position: relative; - color: #ffffff91; + color: var(--fg); + opacity: var(--opacity); margin-top: -13px; font-size: 10px; text-transform: uppercase; @@ -223,7 +224,7 @@ height: 100%; } .background { - background: linear-gradient(to right, var(--primary), #9339f4); + background: linear-gradient(to right, var(--primary), var(--mainnet-alt)); left: 0; right: 0; } diff --git a/frontend/src/app/components/faucet/faucet.component.html b/frontend/src/app/components/faucet/faucet.component.html new file mode 100644 index 000000000..2de642c2c --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.html @@ -0,0 +1,91 @@ +
+ +
+

Testnet4 Faucet

+
+ +
+ + @if (txid) { +
+ + Sent! + {{ txid }} +
+ } + @else if (loading) { +

Loading faucet...

+
+ } @else if (!user) { + +
+
+ To limit abuse,  + authenticate  + or +
+ +
+ } + @else if (error === 'not_available') { + +
+
+ To limit abuse +
+ +
+ } + + @else if (error) { + + + } + + @if (!loading) { +
+
+
+
+
+
+ Amount (sats) +
+ +
+ + + +
+
+
+
Amount is required
+
Minimum is {{ amount?.errors?.['min'].min | number }} tSats
+
Maximum is {{ amount?.errors?.['max'].max | number }} tSats
+
+
+
+ Address +
+ + +
+
+
Address is required
+
Must be a valid testnet4 address
+
You cannot use this address
+
+
+
+
+
+ } + + + @if (status?.address) { +
If you no longer need your testnet4 coins, please consider sending them back to replenish the faucet.
+ } + +
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/faucet/faucet.component.scss b/frontend/src/app/components/faucet/faucet.component.scss new file mode 100644 index 000000000..d611f5a23 --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.scss @@ -0,0 +1,52 @@ +.formGroup { + width: 100%; +} + +.input-group { + display: flex; + flex-wrap: wrap; + align-items: stretch; + justify-content: flex-end; + row-gap: 0.5rem; + gap: 0.5rem; + + .form-control { + min-width: 160px; + flex-grow: 100; + } + + .button-group { + display: flex; + align-items: stretch; + } + + .submit-button, .button-group, .button-group .btn { + flex-grow: 1; + } + .submit-button:disabled { + pointer-events: none; + } + + #satoshis::after { + content: 'sats'; + position: absolute; + right: 0.5em; + top: 0; + bottom: 0; + } +} + +.faucet-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: 100%; + max-width: 800px; + margin: auto; +} + +.invalid { + border-width: 1px; + border-color: var(--red); +} diff --git a/frontend/src/app/components/faucet/faucet.component.ts b/frontend/src/app/components/faucet/faucet.component.ts new file mode 100644 index 000000000..bfb485d0e --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.ts @@ -0,0 +1,155 @@ +import { Component, OnDestroy, OnInit, ChangeDetectorRef } from "@angular/core"; +import { FormBuilder, FormGroup, Validators, ValidatorFn, AbstractControl, ValidationErrors } from "@angular/forms"; +import { Subscription } from "rxjs"; +import { StorageService } from "../../services/storage.service"; +import { ServicesApiServices } from "../../services/services-api.service"; +import { getRegex } from "../../shared/regex.utils"; +import { StateService } from "../../services/state.service"; +import { WebsocketService } from "../../services/websocket.service"; +import { AudioService } from "../../services/audio.service"; +import { HttpErrorResponse } from "@angular/common/http"; + +@Component({ + selector: 'app-faucet', + templateUrl: './faucet.component.html', + styleUrls: ['./faucet.component.scss'] +}) +export class FaucetComponent implements OnInit, OnDestroy { + loading = true; + error: string = ''; + user: any = undefined; + txid: string = ''; + + faucetStatusSubscription: Subscription; + status: { + min: number; // minimum amount to request at once (in sats) + max: number; // maximum amount to request at once + address?: string; // faucet address + code: 'ok' | 'faucet_not_available' | 'faucet_maximum_reached' | 'faucet_too_soon'; + } | null = null; + faucetForm: FormGroup; + + mempoolPositionSubscription: Subscription; + confirmationSubscription: Subscription; + + constructor( + private cd: ChangeDetectorRef, + private storageService: StorageService, + private servicesApiService: ServicesApiServices, + private formBuilder: FormBuilder, + private stateService: StateService, + private websocketService: WebsocketService, + private audioService: AudioService + ) { + this.faucetForm = this.formBuilder.group({ + 'address': ['', [Validators.required, Validators.pattern(getRegex('address', 'testnet4'))]], + 'satoshis': [0, [Validators.required, Validators.min(0), Validators.max(0)]] + }); + } + + ngOnDestroy() { + this.stateService.markBlock$.next({}); + this.websocketService.stopTrackingTransaction(); + if (this.mempoolPositionSubscription) { + this.mempoolPositionSubscription.unsubscribe(); + } + if (this.confirmationSubscription) { + this.confirmationSubscription.unsubscribe(); + } + } + + ngOnInit() { + this.user = this.storageService.getAuth()?.user ?? null; + if (!this.user) { + this.loading = false; + return; + } + + // Setup form + this.faucetStatusSubscription = this.servicesApiService.getFaucetStatus$().subscribe({ + next: (status) => { + if (!status) { + this.error = 'internal_server_error'; + return; + } + this.status = status; + + const notFaucetAddressValidator = (faucetAddress: string): ValidatorFn => { + return (control: AbstractControl): ValidationErrors | null => { + const forbidden = control.value === faucetAddress; + return forbidden ? { forbiddenAddress: { value: control.value } } : null; + }; + } + this.faucetForm = this.formBuilder.group({ + 'address': ['', [Validators.required, Validators.pattern(getRegex('address', 'testnet4')), notFaucetAddressValidator(this.status.address)]], + 'satoshis': [this.status.min, [Validators.required, Validators.min(this.status.min), Validators.max(this.status.max)]] + }); + + if (this.status.code !== 'ok') { + this.error = this.status.code; + } + + this.loading = false; + this.cd.markForCheck(); + }, + error: (response) => { + this.loading = false; + this.error = response.error; + this.cd.markForCheck(); + } + }); + + // Track transaction + this.websocketService.want(['blocks', 'mempool-blocks']); + this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { + if (txPosition && txPosition.txid === this.txid) { + this.stateService.markBlock$.next({ + txid: txPosition.txid, + mempoolPosition: txPosition.position, + }); + } + }); + + this.confirmationSubscription = this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => { + if (txConfirmed && txConfirmed === this.txid) { + this.stateService.markBlock$.next({ blockHeight: block.height }); + } + }); + } + + requestCoins(): void { + this.error = null; + this.txid = ''; + this.stateService.markBlock$.next({}); + this.servicesApiService.requestTestnet4Coins$(this.faucetForm.get('address')?.value, parseInt(this.faucetForm.get('satoshis')?.value)) + .subscribe({ + next: ((response) => { + this.txid = response.txid; + this.websocketService.startTrackTransaction(this.txid); + this.audioService.playSound('cha-ching'); + this.cd.markForCheck(); + }), + error: (response: HttpErrorResponse) => { + this.error = response.error; + }, + }); + } + + setAmount(value: number): void { + if (this.faucetForm) { + this.faucetForm.get('satoshis').setValue(value); + } + } + + get amount() { return this.faucetForm.get('satoshis')!; } + get invalidAmount() { + const amount = this.faucetForm.get('satoshis')!; + return amount?.invalid && (amount.dirty || amount.touched) + } + + get address() { return this.faucetForm.get('address')!; } + get invalidAddress() { + const address = this.faucetForm.get('address')!; + return address?.invalid && (address.dirty || address.touched) + } +} diff --git a/frontend/src/app/components/fees-box/fees-box.component.scss b/frontend/src/app/components/fees-box/fees-box.component.scss index 0272936ee..c5843f58b 100644 --- a/frontend/src/app/components/fees-box/fees-box.component.scss +++ b/frontend/src/app/components/fees-box/fees-box.component.scss @@ -79,7 +79,7 @@ display: flex; flex-direction: row; transition: background-color 1s; - color: var(--color-fg); + color: #fff; &.priority { @media (767px < width < 992px), (width < 576px) { width: 100%; diff --git a/frontend/src/app/components/fees-box/fees-box.component.ts b/frontend/src/app/components/fees-box/fees-box.component.ts index e923b26e9..78fd102ca 100644 --- a/frontend/src/app/components/fees-box/fees-box.component.ts +++ b/frontend/src/app/components/fees-box/fees-box.component.ts @@ -16,8 +16,8 @@ export class FeesBoxComponent implements OnInit, OnDestroy { isLoading$: Observable; recommendedFees$: Observable; themeSubscription: Subscription; - gradient = 'linear-gradient(to right, #2e324e, #2e324e)'; - noPriority = '#2e324e'; + gradient = 'linear-gradient(to right, var(--skeleton-bg), var(--skeleton-bg))'; + noPriority = 'var(--skeleton-bg)'; fees: Recommendedfees; constructor( diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index 94241b825..294d32c1e 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -1,9 +1,9 @@ -