diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 3371a8587..e3df7d2fe 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -29,7 +29,9 @@ "ADVANCED_GBT_MEMPOOL": false, "RUST_GBT": false, "CPFP_INDEXING": false, - "DISK_CACHE_BLOCK_INTERVAL": 6 + "DISK_CACHE_BLOCK_INTERVAL": 6, + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "CORE_RPC": { "HOST": "127.0.0.1", @@ -123,5 +125,16 @@ "LIQUID_ONION": "http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1", "BISQ_URL": "https://bisq.markets/api", "BISQ_ONION": "http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api" + }, + "REPLICATION": { + "ENABLED": false, + "AUDIT": false, + "AUDIT_START_HEIGHT": 774000, + "SERVERS": [ + "list", + "of", + "trusted", + "servers" + ] } } diff --git a/backend/package-lock.json b/backend/package-lock.json index f277f6d6d..6800c24c0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mempool-backend", - "version": "2.6.0-dev", + "version": "3.0.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mempool-backend", - "version": "2.6.0-dev", + "version": "3.0.0-dev", "license": "GNU Affero General Public License v3.0", "dependencies": { "@babel/core": "^7.21.3", @@ -7570,7 +7570,7 @@ "name": "gbt", "version": "0.1.0", "hasInstallScript": true, - "devDependencies": { + "dependencies": { "@napi-rs/cli": "^2.16.1" }, "engines": { diff --git a/backend/package.json b/backend/package.json index bc4771ebb..5cfb7982e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mempool-backend", - "version": "2.6.0-dev", + "version": "3.0.0-dev", "description": "Bitcoin mempool visualizer and blockchain explorer backend", "license": "GNU Affero General Public License v3.0", "homepage": "https://mempool.space", diff --git a/backend/rust-gbt/package-lock.json b/backend/rust-gbt/package-lock.json index e20a6f5bf..b7949b9ab 100644 --- a/backend/rust-gbt/package-lock.json +++ b/backend/rust-gbt/package-lock.json @@ -1,12 +1,12 @@ { "name": "gbt", - "version": "0.1.0", + "version": "3.0.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gbt", - "version": "0.1.0", + "version": "3.0.0-dev", "hasInstallScript": true, "dependencies": { "@napi-rs/cli": "^2.16.1" diff --git a/backend/rust-gbt/package.json b/backend/rust-gbt/package.json index e65fb2209..c95f36b06 100644 --- a/backend/rust-gbt/package.json +++ b/backend/rust-gbt/package.json @@ -1,6 +1,6 @@ { "name": "gbt", - "version": "0.1.0", + "version": "3.0.0-dev", "description": "An inefficient re-implementation of the getBlockTemplate algorithm in Rust", "main": "index.js", "types": "index.d.ts", @@ -30,4 +30,4 @@ "engines": { "node": ">= 12" } -} \ No newline at end of file +} diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index 62b2e5f45..4213f0ffb 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -30,7 +30,9 @@ "RUST_GBT": false, "CPFP_INDEXING": true, "MAX_BLOCKS_BULK_QUERY": 999, - "DISK_CACHE_BLOCK_INTERVAL": 999 + "DISK_CACHE_BLOCK_INTERVAL": 999, + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "CORE_RPC": { "HOST": "__CORE_RPC_HOST__", @@ -119,5 +121,11 @@ }, "CLIGHTNING": { "SOCKET": "__CLIGHTNING_SOCKET__" + }, + "REPLICATION": { + "ENABLED": false, + "AUDIT": false, + "AUDIT_START_HEIGHT": 774000, + "SERVERS": [] } -} \ No newline at end of file +} diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index 937011ba2..dc1beaa46 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -44,6 +44,8 @@ describe('Mempool Backend Config', () => { CPFP_INDEXING: false, MAX_BLOCKS_BULK_QUERY: 0, DISK_CACHE_BLOCK_INTERVAL: 6, + MAX_PUSH_TX_SIZE_WEIGHT: 400000, + ALLOW_UNREACHABLE: true, }); expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); @@ -118,6 +120,13 @@ describe('Mempool Backend Config', () => { GEOLITE2_ASN: '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb', GEOIP2_ISP: '/usr/local/share/GeoIP/GeoIP2-ISP.mmdb' }); + + expect(config.REPLICATION).toStrictEqual({ + ENABLED: false, + AUDIT: false, + AUDIT_START_HEIGHT: 774000, + SERVERS: [] + }); }); }); diff --git a/backend/src/api/audit.ts b/backend/src/api/audit.ts index e79196a7a..f7aecfca8 100644 --- a/backend/src/api/audit.ts +++ b/backend/src/api/audit.ts @@ -1,12 +1,12 @@ import config from '../config'; import logger from '../logger'; -import { TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces'; +import { MempoolTransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces'; import rbfCache from './rbf-cache'; const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners class Audit { - auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended }) + auditBlock(transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended }) : { censored: string[], added: string[], fresh: string[], sigop: string[], fullrbf: string[], score: number, similarity: number } { if (!projectedBlocks?.[0]?.transactionIds || !mempool) { return { censored: [], added: [], fresh: [], sigop: [], fullrbf: [], score: 0, similarity: 1 }; @@ -14,7 +14,7 @@ class Audit { const matches: string[] = []; // present in both mined block and template const added: string[] = []; // present in mined block, not in template - const fresh: string[] = []; // missing, but firstSeen within PROPAGATION_MARGIN + const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN const fullrbf: string[] = []; // either missing or present, and part of a fullrbf replacement const isCensored = {}; // missing, without excuse const isDisplaced = {}; @@ -36,10 +36,13 @@ class Audit { // look for transactions that were expected in the template, but missing from the mined block for (const txid of projectedBlocks[0].transactionIds) { if (!inBlock[txid]) { - // tx is recent, may have reached the miner too late for inclusion if (rbfCache.isFullRbf(txid)) { fullrbf.push(txid); } else if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) { + // tx is recent, may have reached the miner too late for inclusion + fresh.push(txid); + } else if (mempool[txid]?.lastBoosted != null && (now - (mempool[txid]?.lastBoosted || 0)) <= PROPAGATION_MARGIN) { + // tx was recently cpfp'd, miner may not have the latest effective rate fresh.push(txid); } else { isCensored[txid] = true; diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index a0cc41770..cbcb2c571 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -65,17 +65,11 @@ class BitcoinApi implements AbstractBitcoinApi { } $getBlockHeightTip(): Promise { - return this.bitcoindClient.getChainTips() - .then((result: IBitcoinApi.ChainTips[]) => { - return result.find(tip => tip.status === 'active')!.height; - }); + return this.bitcoindClient.getBlockCount(); } $getBlockHashTip(): Promise { - return this.bitcoindClient.getChainTips() - .then((result: IBitcoinApi.ChainTips[]) => { - return result.find(tip => tip.status === 'active')!.hash; - }); + return this.bitcoindClient.getBestBlockHash(); } $getTxIdsForBlock(hash: string): Promise { diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 8f31e152d..babc0aa53 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -121,7 +121,6 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', this.getBlockHeight) .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress) .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions) - .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs/chain/:txId', this.getAddressTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', this.getAddressPrefix) ; } @@ -546,27 +545,28 @@ class BitcoinRoutes { } } - private async getAddressTransactions(req: Request, res: Response) { + private async getAddressTransactions(req: Request, res: Response): Promise { if (config.MEMPOOL.BACKEND === 'none') { res.status(405).send('Address lookups cannot be used with bitcoind as backend.'); return; } try { - const transactions = await bitcoinApi.$getAddressTransactions(req.params.address, req.params.txId); + let lastTxId: string = ''; + if (req.query.after_txid && typeof req.query.after_txid === 'string') { + lastTxId = req.query.after_txid; + } + const transactions = await bitcoinApi.$getAddressTransactions(req.params.address, lastTxId); res.json(transactions); } catch (e) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { - return res.status(413).send(e instanceof Error ? e.message : e); + res.status(413).send(e instanceof Error ? e.message : e); + return; } res.status(500).send(e instanceof Error ? e.message : e); } } - private async getAdressTxChain(req: Request, res: Response) { - res.status(501).send('Not implemented'); - } - private async getAddressPrefix(req: Request, res: Response) { try { const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); @@ -723,12 +723,7 @@ class BitcoinRoutes { private async $postTransaction(req: Request, res: Response) { res.setHeader('content-type', 'text/plain'); try { - let rawTx; - if (typeof req.body === 'object') { - rawTx = Object.keys(req.body)[0]; - } else { - rawTx = req.body; - } + const rawTx = Common.getTransactionFromRequest(req, false); const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx); res.send(txIdResult); } catch (e: any) { @@ -739,12 +734,8 @@ class BitcoinRoutes { private async $postTransactionForm(req: Request, res: Response) { res.setHeader('content-type', 'text/plain'); - const matches = /tx=([a-z0-9]+)/.exec(req.body); - let txHex = ''; - if (matches && matches[1]) { - txHex = matches[1]; - } try { + const txHex = Common.getTransactionFromRequest(req, true); const txIdResult = await bitcoinClient.sendRawTransaction(txHex); res.send(txIdResult); } catch (e: any) { diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 4d218ed54..fdf32f438 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -25,6 +25,7 @@ import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmen import PricesRepository from '../repositories/PricesRepository'; import priceUpdater from '../tasks/price-updater'; import chainTips from './chain-tips'; +import websocketHandler from './websocket-handler'; class Blocks { private blocks: BlockExtended[] = []; @@ -75,11 +76,14 @@ class Blocks { blockHash: string, blockHeight: number, onlyCoinbase: boolean, + txIds: string[] | null = null, quiet: boolean = false, addMempoolData: boolean = false, ): Promise { const transactions: TransactionExtended[] = []; - const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash); + if (!txIds) { + txIds = await bitcoinApi.$getTxIdsForBlock(blockHash); + } const mempool = memPool.getMempool(); let transactionsFound = 0; @@ -553,7 +557,7 @@ class Blocks { } const blockHash = await bitcoinApi.$getBlockHash(blockHeight); const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); - const transactions = await this.$getTransactionsExtended(blockHash, block.height, true, true); + const transactions = await this.$getTransactionsExtended(blockHash, block.height, true, null, true); const blockExtended = await this.$getBlockExtended(block, transactions); newlyIndexed++; @@ -585,7 +589,7 @@ class Blocks { let fastForwarded = false; let handledBlocks = 0; - const blockHeightTip = await bitcoinApi.$getBlockHeightTip(); + const blockHeightTip = await bitcoinCoreApi.$getBlockHeightTip(); this.updateTimerProgress(timer, 'got block height tip'); if (this.blocks.length === 0) { @@ -638,11 +642,11 @@ class Blocks { } this.updateTimerProgress(timer, `getting block data for ${this.currentBlockHeight}`); - const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight); + const blockHash = await bitcoinCoreApi.$getBlockHash(this.currentBlockHeight); const verboseBlock = await bitcoinClient.getBlock(blockHash, 2); const block = BitcoinApi.convertBlock(verboseBlock); - const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash); - const transactions = await this.$getTransactionsExtended(blockHash, block.height, false, false, true) as MempoolTransactionExtended[]; + const txIds: string[] = verboseBlock.tx.map(tx => tx.txid); + const transactions = await this.$getTransactionsExtended(blockHash, block.height, false, txIds, false, true) as MempoolTransactionExtended[]; if (config.MEMPOOL.BACKEND !== 'esplora') { // fill in missing transaction fee data from verboseBlock for (let i = 0; i < transactions.length; i++) { @@ -686,6 +690,8 @@ class Blocks { this.updateTimerProgress(timer, `reindexed difficulty adjustments`); logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining); indexer.reindex(); + + websocketHandler.handleReorg(); } } diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 9836559ae..cd9da3d2a 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -1,3 +1,5 @@ +import * as bitcoinjs from 'bitcoinjs-lib'; +import { Request } from 'express'; import { Ancestor, CpfpInfo, CpfpSummary, CpfpCluster, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces'; import config from '../config'; import { NodeSocket } from '../repositories/NodesSocketsRepository'; @@ -86,19 +88,19 @@ export class Common { const match = spendMap.get(`${vin.txid}:${vin.vout}`); if (match && match.txid !== tx.txid) { replaced.add(match); + // remove this tx from the spendMap + // prevents the same tx being replaced more than once + for (const replacedVin of match.vin) { + const key = `${replacedVin.txid}:${replacedVin.vout}`; + spendMap.delete(key); + } } + const key = `${vin.txid}:${vin.vout}`; + spendMap.delete(key); } if (replaced.size) { matches[tx.txid] = { replaced: Array.from(replaced), replacedBy: tx }; } - // remove this tx from the spendMap - // prevents the same tx being replaced more than once - for (const vin of tx.vin) { - const key = `${vin.txid}:${vin.vout}`; - if (spendMap.get(key)?.txid === tx.txid) { - spendMap.delete(key); - } - } } return matches; } @@ -511,6 +513,115 @@ export class Common { static getNthPercentile(n: number, sortedDistribution: any[]): any { return sortedDistribution[Math.floor((sortedDistribution.length - 1) * (n / 100))]; } + + static getTransactionFromRequest(req: Request, form: boolean): string { + let rawTx: any = typeof req.body === 'object' && form + ? Object.values(req.body)[0] as any + : req.body; + if (typeof rawTx !== 'string') { + throw Object.assign(new Error('Non-string request body'), { code: -1 }); + } + + // Support both upper and lower case hex + // Support both txHash= Form and direct API POST + const reg = form ? /^txHash=((?:[a-fA-F0-9]{2})+)$/ : /^((?:[a-fA-F0-9]{2})+)$/; + const matches = reg.exec(rawTx); + if (!matches || !matches[1]) { + throw Object.assign(new Error('Non-hex request body'), { 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 + + // We assume txhex to be valid hex (output of getTransactionFromRequest above) + + // Check 1: Valid transaction parse + let tx: bitcoinjs.Transaction; + try { + tx = bitcoinjs.Transaction.fromHex(txhex); + } catch(e) { + throw Object.assign(new Error('Invalid transaction (could not parse)'), { code: -4 }); + } + + // Check 2: Simple size check + if (tx.weight() > config.MEMPOOL.MAX_PUSH_TX_SIZE_WEIGHT) { + throw Object.assign(new Error(`Transaction too large (max ${config.MEMPOOL.MAX_PUSH_TX_SIZE_WEIGHT} weight units)`), { code: -3 }); + } + + // Check 3: Check unreachable script in taproot (if not allowed) + if (!config.MEMPOOL.ALLOW_UNREACHABLE) { + tx.ins.forEach(input => { + const witness = input.witness; + // See BIP 341: Script validation rules + const hasAnnex = witness.length >= 2 && + witness[witness.length - 1][0] === 0x50; + const scriptSpendMinLength = hasAnnex ? 3 : 2; + const maybeScriptSpend = witness.length >= scriptSpendMinLength; + + if (maybeScriptSpend) { + const controlBlock = witness[witness.length - scriptSpendMinLength + 1]; + if (controlBlock.length === 0 || !this.isValidLeafVersion(controlBlock[0])) { + // Skip this input, it's not taproot + return; + } + // Definitely taproot. Get script + const script = witness[witness.length - scriptSpendMinLength]; + const decompiled = bitcoinjs.script.decompile(script); + if (!decompiled || decompiled.length < 2) { + // Skip this input + return; + } + // Iterate up to second last (will look ahead 1 item) + for (let i = 0; i < decompiled.length - 1; i++) { + const first = decompiled[i]; + const second = decompiled[i + 1]; + if ( + first === bitcoinjs.opcodes.OP_FALSE && + second === bitcoinjs.opcodes.OP_IF + ) { + throw Object.assign(new Error('Unreachable taproot scripts not allowed'), { code: -5 }); + } + } + } + }) + } + + // Pass through the input string untouched + return txhex; + } + + private static isValidLeafVersion(leafVersion: number): boolean { + // See Note 7 in BIP341 + // https://github.com/bitcoin/bips/blob/66a1a8151021913047934ebab3f8883f2f8ca75b/bip-0341.mediawiki#cite_note-7 + // "What constraints are there on the leaf version?" + + // Must be an integer between 0 and 255 + // Since we're parsing a byte + if (Math.floor(leafVersion) !== leafVersion || leafVersion < 0 || leafVersion > 255) { + return false; + } + // "the leaf version cannot be odd" + if ((leafVersion & 0x01) === 1) { + return false; + } + // "The values that comply to this rule are + // the 32 even values between 0xc0 and 0xfe + if (leafVersion >= 0xc0 && leafVersion <= 0xfe) { + return true; + } + // and also 0x66, 0x7e, 0x80, 0x84, 0x96, 0x98, 0xba, 0xbc, 0xbe." + if ([0x66, 0x7e, 0x80, 0x84, 0x96, 0x98, 0xba, 0xbc, 0xbe].includes(leafVersion)) { + return true; + } + // Otherwise, invalid + return false; + } } /** diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index a9266a016..7c7608aff 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; import { RowDataPacket } from 'mysql2'; class DatabaseMigration { - private static currentVersion = 63; + private static currentVersion = 64; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -543,6 +543,11 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fullrbf_txs JSON DEFAULT "[]"'); await this.updateToSchemaVersion(63); } + + if (databaseSchemaVersion < 64 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `nodes` ADD features text NULL'); + await this.updateToSchemaVersion(64); + } } /** diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index d429299e1..22f9ca48a 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -3,6 +3,7 @@ import DB from '../../database'; import { ResultSetHeader } from 'mysql2'; import { ILightningApi } from '../lightning/lightning-api.interface'; import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces'; +import { bin2hex } from '../../utils/format'; class NodesApi { public async $getWorldNodes(): Promise { @@ -56,7 +57,8 @@ class NodesApi { UNIX_TIMESTAMP(updated_at) AS updated_at, color, sockets as sockets, as_number, city_id, country_id, subdivision_id, longitude, latitude, geo_names_iso.names as iso_code, geo_names_as.names as as_organization, geo_names_city.names as city, - geo_names_country.names as country, geo_names_subdivision.names as subdivision + geo_names_country.names as country, geo_names_subdivision.names as subdivision, + features FROM nodes LEFT JOIN geo_names geo_names_as on geo_names_as.id = as_number LEFT JOIN geo_names geo_names_city on geo_names_city.id = city_id @@ -76,6 +78,23 @@ class NodesApi { node.city = JSON.parse(node.city); node.country = JSON.parse(node.country); + // Features + node.features = JSON.parse(node.features); + node.featuresBits = null; + if (node.features) { + let maxBit = 0; + for (const feature of node.features) { + maxBit = Math.max(maxBit, feature.bit); + } + maxBit = Math.ceil(maxBit / 4) * 4 - 1; + + node.featuresBits = new Array(maxBit + 1).fill(0); + for (const feature of node.features) { + node.featuresBits[feature.bit] = 1; + } + node.featuresBits = bin2hex(node.featuresBits.reverse().join('')); + } + // Active channels and capacity const activeChannelsStats: any = await this.$getActiveChannelsStats(public_key); node.active_channel_count = activeChannelsStats.active_channel_count ?? 0; @@ -656,10 +675,19 @@ class NodesApi { alias_search, color, sockets, - status + status, + features ) - VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, ?, 1) - ON DUPLICATE KEY UPDATE updated_at = FROM_UNIXTIME(?), alias = ?, alias_search = ?, color = ?, sockets = ?, status = 1`; + VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE + updated_at = FROM_UNIXTIME(?), + alias = ?, + alias_search = ?, + color = ?, + sockets = ?, + status = 1, + features = ? + `; await DB.query(query, [ node.pub_key, @@ -668,11 +696,13 @@ class NodesApi { this.aliasToSearchText(node.alias), node.color, sockets, + JSON.stringify(node.features), node.last_update, node.alias, this.aliasToSearchText(node.alias), node.color, sockets, + JSON.stringify(node.features), ]); } catch (e) { logger.err('$saveNode() error: ' + (e instanceof Error ? e.message : e)); diff --git a/backend/src/api/lightning/clightning/clightning-convert.ts b/backend/src/api/lightning/clightning/clightning-convert.ts index 084965383..771dabcd7 100644 --- a/backend/src/api/lightning/clightning/clightning-convert.ts +++ b/backend/src/api/lightning/clightning/clightning-convert.ts @@ -2,8 +2,91 @@ import { ILightningApi } from '../lightning-api.interface'; import FundingTxFetcher from '../../../tasks/lightning/sync-tasks/funding-tx-fetcher'; import logger from '../../../logger'; import { Common } from '../../common'; +import { hex2bin } from '../../../utils/format'; import config from '../../../config'; +// https://github.com/lightningnetwork/lnd/blob/master/lnwire/features.go +export enum FeatureBits { + DataLossProtectRequired = 0, + DataLossProtectOptional = 1, + InitialRoutingSync = 3, + UpfrontShutdownScriptRequired = 4, + UpfrontShutdownScriptOptional = 5, + GossipQueriesRequired = 6, + GossipQueriesOptional = 7, + TLVOnionPayloadRequired = 8, + TLVOnionPayloadOptional = 9, + StaticRemoteKeyRequired = 12, + StaticRemoteKeyOptional = 13, + PaymentAddrRequired = 14, + PaymentAddrOptional = 15, + MPPRequired = 16, + MPPOptional = 17, + WumboChannelsRequired = 18, + WumboChannelsOptional = 19, + AnchorsRequired = 20, + AnchorsOptional = 21, + AnchorsZeroFeeHtlcTxRequired = 22, + AnchorsZeroFeeHtlcTxOptional = 23, + ShutdownAnySegwitRequired = 26, + ShutdownAnySegwitOptional = 27, + AMPRequired = 30, + AMPOptional = 31, + ExplicitChannelTypeRequired = 44, + ExplicitChannelTypeOptional = 45, + ScidAliasRequired = 46, + ScidAliasOptional = 47, + PaymentMetadataRequired = 48, + PaymentMetadataOptional = 49, + ZeroConfRequired = 50, + ZeroConfOptional = 51, + KeysendRequired = 54, + KeysendOptional = 55, + ScriptEnforcedLeaseRequired = 2022, + ScriptEnforcedLeaseOptional = 2023, + MaxBolt11Feature = 5114, +}; + +export const FeaturesMap = new Map([ + [FeatureBits.DataLossProtectRequired, 'data-loss-protect'], + [FeatureBits.DataLossProtectOptional, 'data-loss-protect'], + [FeatureBits.InitialRoutingSync, 'initial-routing-sync'], + [FeatureBits.UpfrontShutdownScriptRequired, 'upfront-shutdown-script'], + [FeatureBits.UpfrontShutdownScriptOptional, 'upfront-shutdown-script'], + [FeatureBits.GossipQueriesRequired, 'gossip-queries'], + [FeatureBits.GossipQueriesOptional, 'gossip-queries'], + [FeatureBits.TLVOnionPayloadRequired, 'tlv-onion'], + [FeatureBits.TLVOnionPayloadOptional, 'tlv-onion'], + [FeatureBits.StaticRemoteKeyOptional, 'static-remote-key'], + [FeatureBits.StaticRemoteKeyRequired, 'static-remote-key'], + [FeatureBits.PaymentAddrOptional, 'payment-addr'], + [FeatureBits.PaymentAddrRequired, 'payment-addr'], + [FeatureBits.MPPOptional, 'multi-path-payments'], + [FeatureBits.MPPRequired, 'multi-path-payments'], + [FeatureBits.AnchorsRequired, 'anchor-commitments'], + [FeatureBits.AnchorsOptional, 'anchor-commitments'], + [FeatureBits.AnchorsZeroFeeHtlcTxRequired, 'anchors-zero-fee-htlc-tx'], + [FeatureBits.AnchorsZeroFeeHtlcTxOptional, 'anchors-zero-fee-htlc-tx'], + [FeatureBits.WumboChannelsRequired, 'wumbo-channels'], + [FeatureBits.WumboChannelsOptional, 'wumbo-channels'], + [FeatureBits.AMPRequired, 'amp'], + [FeatureBits.AMPOptional, 'amp'], + [FeatureBits.PaymentMetadataOptional, 'payment-metadata'], + [FeatureBits.PaymentMetadataRequired, 'payment-metadata'], + [FeatureBits.ExplicitChannelTypeOptional, 'explicit-commitment-type'], + [FeatureBits.ExplicitChannelTypeRequired, 'explicit-commitment-type'], + [FeatureBits.KeysendOptional, 'keysend'], + [FeatureBits.KeysendRequired, 'keysend'], + [FeatureBits.ScriptEnforcedLeaseRequired, 'script-enforced-lease'], + [FeatureBits.ScriptEnforcedLeaseOptional, 'script-enforced-lease'], + [FeatureBits.ScidAliasRequired, 'scid-alias'], + [FeatureBits.ScidAliasOptional, 'scid-alias'], + [FeatureBits.ZeroConfRequired, 'zero-conf'], + [FeatureBits.ZeroConfOptional, 'zero-conf'], + [FeatureBits.ShutdownAnySegwitRequired, 'shutdown-any-segwit'], + [FeatureBits.ShutdownAnySegwitOptional, 'shutdown-any-segwit'], +]); + /** * Convert a clightning "listnode" entry to a lnd node entry */ @@ -17,10 +100,36 @@ export function convertNode(clNode: any): ILightningApi.Node { custom_records = undefined; } } + + const nodeFeatures: ILightningApi.Feature[] = []; + const nodeFeaturesBinary = hex2bin(clNode.features).split('').reverse().join(''); + + for (let i = 0; i < nodeFeaturesBinary.length; i++) { + if (nodeFeaturesBinary[i] === '0') { + continue; + } + const feature = FeaturesMap.get(i); + if (!feature) { + nodeFeatures.push({ + bit: i, + name: 'unknown', + is_required: i % 2 === 0, + is_known: false + }); + } else { + nodeFeatures.push({ + bit: i, + name: feature, + is_required: i % 2 === 0, + is_known: true + }); + } + } + return { alias: clNode.alias ?? '', color: `#${clNode.color ?? ''}`, - features: [], // TODO parse and return clNode.feature + features: nodeFeatures, pub_key: clNode.nodeid, addresses: clNode.addresses?.map((addr) => { let address = addr.address; diff --git a/backend/src/api/lightning/lightning-api.interface.ts b/backend/src/api/lightning/lightning-api.interface.ts index cd5cb973d..ef26646a0 100644 --- a/backend/src/api/lightning/lightning-api.interface.ts +++ b/backend/src/api/lightning/lightning-api.interface.ts @@ -79,6 +79,7 @@ export namespace ILightningApi { } export interface Feature { + bit: number; name: string; is_required: boolean; is_known: boolean; diff --git a/backend/src/api/lightning/lnd/lnd-api.ts b/backend/src/api/lightning/lnd/lnd-api.ts index b4c91d36e..f4099e82b 100644 --- a/backend/src/api/lightning/lnd/lnd-api.ts +++ b/backend/src/api/lightning/lnd/lnd-api.ts @@ -41,8 +41,23 @@ class LndApi implements AbstractLightningApi { } async $getNetworkGraph(): Promise { - return axios.get(config.LND.REST_API_URL + '/v1/graph', this.axiosConfig) + const graph = await axios.get(config.LND.REST_API_URL + '/v1/graph', this.axiosConfig) .then((response) => response.data); + + for (const node of graph.nodes) { + const nodeFeatures: ILightningApi.Feature[] = []; + for (const bit in node.features) { + nodeFeatures.push({ + bit: parseInt(bit, 10), + name: node.features[bit].name, + is_required: node.features[bit].is_required, + is_known: node.features[bit].is_known, + }); + } + node.features = nodeFeatures; + } + + return graph; } } diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index d5538854a..08508310d 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -457,6 +457,7 @@ class MempoolBlocks { }; if (matched) { descendants.push(relative); + mempoolTx.lastBoosted = Math.max(mempoolTx.lastBoosted || 0, mempool[txid].firstSeen || 0); } else { ancestors.push(relative); } diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 79d5ff2d1..367ba1c0e 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -6,6 +6,7 @@ import { Common } from "./common"; interface RbfTransaction extends TransactionStripped { rbf?: boolean; mined?: boolean; + fullRbf?: boolean; } interface RbfTree { @@ -17,6 +18,16 @@ interface RbfTree { replaces: RbfTree[]; } +export interface ReplacementInfo { + mined: boolean; + fullRbf: boolean; + txid: string; + oldFee: number; + oldVsize: number; + newFee: number; + newVsize: number; +} + class RbfCache { private replacedBy: Map = new Map(); private replaces: Map = new Map(); @@ -41,11 +52,15 @@ class RbfCache { this.txs.set(newTx.txid, newTxExtended); // maintain rbf trees - let fullRbf = false; + let txFullRbf = false; + let treeFullRbf = false; const replacedTrees: RbfTree[] = []; for (const replacedTxExtended of replaced) { const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction; replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe); + if (!replacedTx.rbf) { + txFullRbf = true; + } this.replacedBy.set(replacedTx.txid, newTx.txid); if (this.treeMap.has(replacedTx.txid)) { const treeId = this.treeMap.get(replacedTx.txid); @@ -55,7 +70,7 @@ class RbfCache { if (tree) { tree.interval = newTime - tree?.time; replacedTrees.push(tree); - fullRbf = fullRbf || tree.fullRbf; + treeFullRbf = treeFullRbf || tree.fullRbf || !tree.tx.rbf; } } } else { @@ -67,15 +82,16 @@ class RbfCache { fullRbf: !replacedTx.rbf, replaces: [], }); - fullRbf = fullRbf || !replacedTx.rbf; + treeFullRbf = treeFullRbf || !replacedTx.rbf; this.txs.set(replacedTx.txid, replacedTxExtended); } } + newTx.fullRbf = txFullRbf; const treeId = replacedTrees[0].tx.txid; const newTree = { tx: newTx, time: newTime, - fullRbf, + fullRbf: treeFullRbf, replaces: replacedTrees }; this.rbfTrees.set(treeId, newTree); @@ -349,6 +365,27 @@ class RbfCache { } return tree; } + + public getLatestRbfSummary(): ReplacementInfo[] { + const rbfList = this.getRbfTrees(false); + return rbfList.slice(0, 6).map(rbfTree => { + let oldFee = 0; + let oldVsize = 0; + for (const replaced of rbfTree.replaces) { + oldFee += replaced.tx.fee; + oldVsize += replaced.tx.vsize; + } + return { + txid: rbfTree.tx.txid, + mined: !!rbfTree.tx.mined, + fullRbf: !!rbfTree.tx.fullRbf, + oldFee, + oldVsize, + newFee: rbfTree.tx.fee, + newVsize: rbfTree.tx.vsize, + }; + }); + } } export default new RbfCache(); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index ae536b72e..a0c031175 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -12,7 +12,7 @@ import { Common } from './common'; import loadingIndicators from './loading-indicators'; import config from '../config'; import transactionUtils from './transaction-utils'; -import rbfCache from './rbf-cache'; +import rbfCache, { ReplacementInfo } from './rbf-cache'; import difficultyAdjustment from './difficulty-adjustment'; import feeApi from './fee-api'; import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; @@ -40,6 +40,7 @@ class WebsocketHandler { private socketData: { [key: string]: string } = {}; private serializedInitData: string = '{}'; + private lastRbfSummary: ReplacementInfo | null = null; constructor() { } @@ -225,8 +226,19 @@ class WebsocketHandler { } } + if (parsedMessage && parsedMessage['track-rbf-summary'] != null) { + if (parsedMessage['track-rbf-summary']) { + client['track-rbf-summary'] = true; + if (this.socketData['rbfSummary'] != null) { + response['rbfLatestSummary'] = this.socketData['rbfSummary']; + } + } else { + client['track-rbf-summary'] = false; + } + } + if (parsedMessage.action === 'init') { - if (!this.socketData['blocks']?.length || !this.socketData['da']) { + if (!this.socketData['blocks']?.length || !this.socketData['da'] || !this.socketData['backendInfo'] || !this.socketData['conversions']) { this.updateSocketData(); } if (!this.socketData['blocks']?.length) { @@ -333,6 +345,40 @@ class WebsocketHandler { }); } + handleReorg(): void { + if (!this.wss) { + throw new Error('WebSocket.Server is not set'); + } + + const da = difficultyAdjustment.getDifficultyAdjustment(); + + // update init data + this.updateSocketDataFields({ + 'blocks': blocks.getBlocks(), + 'da': da?.previousTime ? da : undefined, + }); + + this.wss.clients.forEach((client) => { + if (client.readyState !== WebSocket.OPEN) { + return; + } + + const response = {}; + + if (client['want-blocks']) { + response['blocks'] = this.socketData['blocks']; + } + if (client['want-stats']) { + response['da'] = this.socketData['da']; + } + + if (Object.keys(response).length) { + const serializedResponse = this.serializeResponse(response); + client.send(serializedResponse); + } + }); + } + async $handleMempoolChange(newMempool: { [txid: string]: MempoolTransactionExtended }, mempoolSize: number, newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[]): Promise { if (!this.wss) { @@ -361,10 +407,13 @@ class WebsocketHandler { const rbfChanges = rbfCache.getRbfChanges(); let rbfReplacements; let fullRbfReplacements; + let rbfSummary; if (Object.keys(rbfChanges.trees).length) { rbfReplacements = rbfCache.getRbfTrees(false); fullRbfReplacements = rbfCache.getRbfTrees(true); + rbfSummary = rbfCache.getLatestRbfSummary(); } + for (const deletedTx of deletedTransactions) { rbfCache.evict(deletedTx.txid); } @@ -372,10 +421,10 @@ class WebsocketHandler { memPool.addToSpendMap(newTransactions); const recommendedFees = feeApi.getRecommendedFee(); - const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx)); + const latestTransactions = memPool.getLatestTransactions(); // update init data - this.updateSocketDataFields({ + const socketDataFields = { 'mempoolInfo': mempoolInfo, 'vBytesPerSecond': vBytesPerSecond, 'mempool-blocks': mBlocks, @@ -383,7 +432,11 @@ class WebsocketHandler { 'loadingIndicators': loadingIndicators.getLoadingIndicators(), 'da': da?.previousTime ? da : undefined, 'fees': recommendedFees, - }); + }; + if (rbfSummary) { + socketDataFields['rbfSummary'] = rbfSummary; + } + this.updateSocketDataFields(socketDataFields); // cache serialized objects to avoid stringify-ing the same thing for every client const responseCache = { ...this.socketData }; @@ -567,6 +620,10 @@ class WebsocketHandler { response['rbfLatest'] = getCachedResponse('fullrbfLatest', fullRbfReplacements); } + if (client['track-rbf-summary'] && rbfSummary) { + response['rbfLatestSummary'] = getCachedResponse('rbfLatestSummary', rbfSummary); + } + if (Object.keys(response).length) { const serializedResponse = this.serializeResponse(response); client.send(serializedResponse); diff --git a/backend/src/config.ts b/backend/src/config.ts index fd7d7bc28..09d279537 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -35,6 +35,8 @@ interface IConfig { CPFP_INDEXING: boolean; MAX_BLOCKS_BULK_QUERY: number; DISK_CACHE_BLOCK_INTERVAL: number; + MAX_PUSH_TX_SIZE_WEIGHT: number; + ALLOW_UNREACHABLE: boolean; }; ESPLORA: { REST_API_URL: string; @@ -130,6 +132,12 @@ interface IConfig { GEOLITE2_ASN: string; GEOIP2_ISP: string; }, + REPLICATION: { + ENABLED: boolean; + AUDIT: boolean; + AUDIT_START_HEIGHT: number; + SERVERS: string[]; + } } const defaults: IConfig = { @@ -165,6 +173,8 @@ const defaults: IConfig = { 'CPFP_INDEXING': false, 'MAX_BLOCKS_BULK_QUERY': 0, 'DISK_CACHE_BLOCK_INTERVAL': 6, + 'MAX_PUSH_TX_SIZE_WEIGHT': 400000, + 'ALLOW_UNREACHABLE': true, }, 'ESPLORA': { 'REST_API_URL': 'http://127.0.0.1:3000', @@ -260,6 +270,12 @@ const defaults: IConfig = { 'GEOLITE2_ASN': '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb', 'GEOIP2_ISP': '/usr/local/share/GeoIP/GeoIP2-ISP.mmdb' }, + 'REPLICATION': { + 'ENABLED': false, + 'AUDIT': false, + 'AUDIT_START_HEIGHT': 774000, + 'SERVERS': [], + } }; class Config implements IConfig { @@ -279,6 +295,7 @@ class Config implements IConfig { PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER']; EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER']; MAXMIND: IConfig['MAXMIND']; + REPLICATION: IConfig['REPLICATION']; constructor() { const configs = this.merge(configFromFile, defaults); @@ -298,6 +315,7 @@ class Config implements IConfig { this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER; this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER; this.MAXMIND = configs.MAXMIND; + this.REPLICATION = configs.REPLICATION; } merge = (...objects: object[]): IConfig => { diff --git a/backend/src/index.ts b/backend/src/index.ts index 81863a208..bbfaa9ff3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -169,6 +169,7 @@ class Server { } async runMainUpdateLoop(): Promise { + const start = Date.now(); try { try { await memPool.$updateMemPoolInfo(); @@ -188,7 +189,9 @@ class Server { indexer.$run(); // rerun immediately if we skipped the mempool update, otherwise wait POLL_RATE_MS - setTimeout(this.runMainUpdateLoop.bind(this), numHandledBlocks > 0 ? 1 : config.MEMPOOL.POLL_RATE_MS); + const elapsed = Date.now() - start; + const remainingTime = Math.max(0, config.MEMPOOL.POLL_RATE_MS - elapsed) + setTimeout(this.runMainUpdateLoop.bind(this), numHandledBlocks > 0 ? 0 : remainingTime); this.backendRetryCount = 0; } catch (e: any) { this.backendRetryCount++; diff --git a/backend/src/indexer.ts b/backend/src/indexer.ts index 4b120867f..d89a2647f 100644 --- a/backend/src/indexer.ts +++ b/backend/src/indexer.ts @@ -6,6 +6,8 @@ import logger from './logger'; import bitcoinClient from './api/bitcoin/bitcoin-client'; import priceUpdater from './tasks/price-updater'; import PricesRepository from './repositories/PricesRepository'; +import config from './config'; +import auditReplicator from './replication/AuditReplication'; export interface CoreIndex { name: string; @@ -72,7 +74,7 @@ class Indexer { return; } - if (task === 'blocksPrices' && !this.tasksRunning.includes(task)) { + if (task === 'blocksPrices' && !this.tasksRunning.includes(task) && !['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { this.tasksRunning.push(task); const lastestPriceId = await PricesRepository.$getLatestPriceId(); if (priceUpdater.historyInserted === false || lastestPriceId === null) { @@ -135,6 +137,7 @@ class Indexer { await blocks.$generateBlocksSummariesDatabase(); await blocks.$generateCPFPDatabase(); await blocks.$generateAuditStats(); + await auditReplicator.$sync(); } catch (e) { this.indexerRunning = false; logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e)); diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index a051eea4f..25e7f0387 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -100,6 +100,7 @@ export interface MempoolTransactionExtended extends TransactionExtended { adjustedVsize: number; adjustedFeePerVsize: number; inputs?: number[]; + lastBoosted?: number; } export interface AuditTransaction { @@ -236,6 +237,15 @@ export interface BlockSummary { transactions: TransactionStripped[]; } +export interface AuditSummary extends BlockAudit { + timestamp?: number, + size?: number, + weight?: number, + tx_count?: number, + transactions: TransactionStripped[]; + template?: TransactionStripped[]; +} + export interface BlockPrice { height: number; priceId: number; diff --git a/backend/src/replication/AuditReplication.ts b/backend/src/replication/AuditReplication.ts new file mode 100644 index 000000000..26bf6dad7 --- /dev/null +++ b/backend/src/replication/AuditReplication.ts @@ -0,0 +1,134 @@ +import DB from '../database'; +import logger from '../logger'; +import { AuditSummary } from '../mempool.interfaces'; +import blocksAuditsRepository from '../repositories/BlocksAuditsRepository'; +import blocksSummariesRepository from '../repositories/BlocksSummariesRepository'; +import { $sync } from './replicator'; +import config from '../config'; +import { Common } from '../api/common'; +import blocks from '../api/blocks'; + +const BATCH_SIZE = 16; + +/** + * Syncs missing block template and audit data from trusted servers + */ +class AuditReplication { + inProgress: boolean = false; + skip: Set = new Set(); + + public async $sync(): Promise { + if (!config.REPLICATION.ENABLED || !config.REPLICATION.AUDIT) { + // replication not enabled + return; + } + if (this.inProgress) { + logger.info(`AuditReplication sync already in progress`, 'Replication'); + return; + } + this.inProgress = true; + + const missingAudits = await this.$getMissingAuditBlocks(); + + logger.debug(`Fetching missing audit data for ${missingAudits.length} blocks from trusted servers`, 'Replication'); + + let totalSynced = 0; + let totalMissed = 0; + let loggerTimer = Date.now(); + // process missing audits in batches of + for (let i = 0; i < missingAudits.length; i += BATCH_SIZE) { + const slice = missingAudits.slice(i, i + BATCH_SIZE); + const results = await Promise.all(slice.map(hash => this.$syncAudit(hash))); + const synced = results.reduce((total, status) => status ? total + 1 : total, 0); + totalSynced += synced; + totalMissed += (slice.length - synced); + if (Date.now() - loggerTimer > 10000) { + loggerTimer = Date.now(); + logger.info(`Found ${totalSynced} / ${totalSynced + totalMissed} of ${missingAudits.length} missing audits`, 'Replication'); + } + await Common.sleep$(1000); + } + + logger.debug(`Fetched ${totalSynced} audits, ${totalMissed} still missing`, 'Replication'); + + this.inProgress = false; + } + + private async $syncAudit(hash: string): Promise { + if (this.skip.has(hash)) { + // we already know none of our trusted servers have this audit + return false; + } + + let success = false; + // start with a random server so load is uniformly spread + const syncResult = await $sync(`/api/v1/block/${hash}/audit-summary`); + if (syncResult) { + if (syncResult.data?.template?.length) { + await this.$saveAuditData(hash, syncResult.data); + logger.info(`Imported audit data from ${syncResult.server} for block ${syncResult.data.height} (${hash})`); + success = true; + } + if (!syncResult.data && !syncResult.exists) { + this.skip.add(hash); + } + } + + return success; + } + + private async $getMissingAuditBlocks(): Promise { + try { + const startHeight = config.REPLICATION.AUDIT_START_HEIGHT || 0; + const [rows]: any[] = await DB.query(` + SELECT auditable.hash, auditable.height + FROM ( + SELECT hash, height + FROM blocks + WHERE height >= ? + ) AS auditable + LEFT JOIN blocks_audits ON auditable.hash = blocks_audits.hash + WHERE blocks_audits.hash IS NULL + ORDER BY auditable.height DESC + `, [startHeight]); + return rows.map(row => row.hash); + } catch (e: any) { + logger.err(`Cannot fetch missing audit blocks from db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + + private async $saveAuditData(blockHash: string, auditSummary: AuditSummary): Promise { + // save audit & template to DB + await blocksSummariesRepository.$saveTemplate({ + height: auditSummary.height, + template: { + id: blockHash, + transactions: auditSummary.template || [] + } + }); + await blocksAuditsRepository.$saveAudit({ + hash: blockHash, + height: auditSummary.height, + time: auditSummary.timestamp || auditSummary.time, + missingTxs: auditSummary.missingTxs || [], + addedTxs: auditSummary.addedTxs || [], + freshTxs: auditSummary.freshTxs || [], + sigopTxs: auditSummary.sigopTxs || [], + fullrbfTxs: auditSummary.fullrbfTxs || [], + matchRate: auditSummary.matchRate, + expectedFees: auditSummary.expectedFees, + expectedWeight: auditSummary.expectedWeight, + }); + // add missing data to cached blocks + const cachedBlock = blocks.getBlocks().find(block => block.id === blockHash); + if (cachedBlock) { + cachedBlock.extras.matchRate = auditSummary.matchRate; + cachedBlock.extras.expectedFees = auditSummary.expectedFees || null; + cachedBlock.extras.expectedWeight = auditSummary.expectedWeight || null; + } + } +} + +export default new AuditReplication(); + diff --git a/backend/src/replication/replicator.ts b/backend/src/replication/replicator.ts new file mode 100644 index 000000000..ac204efcc --- /dev/null +++ b/backend/src/replication/replicator.ts @@ -0,0 +1,70 @@ +import config from '../config'; +import backendInfo from '../api/backend-info'; +import axios, { AxiosResponse } from 'axios'; +import { SocksProxyAgent } from 'socks-proxy-agent'; +import * as https from 'https'; + +export async function $sync(path): Promise<{ data?: any, exists: boolean, server?: string }> { + // start with a random server so load is uniformly spread + let allMissing = true; + const offset = Math.floor(Math.random() * config.REPLICATION.SERVERS.length); + for (let i = 0; i < config.REPLICATION.SERVERS.length; i++) { + const server = config.REPLICATION.SERVERS[(i + offset) % config.REPLICATION.SERVERS.length]; + // don't query ourself + if (server === backendInfo.getBackendInfo().hostname) { + continue; + } + + try { + const result = await query(`https://${server}${path}`); + if (result) { + return { data: result, exists: true, server }; + } + } catch (e: any) { + if (e?.response?.status === 404) { + // this server is also missing this data + } else { + // something else went wrong + allMissing = false; + } + } + } + + return { exists: !allMissing }; +} + +export async function query(path): Promise { + type axiosOptions = { + headers: { + 'User-Agent': string + }; + timeout: number; + httpsAgent?: https.Agent; + }; + const axiosOptions: axiosOptions = { + headers: { + 'User-Agent': (config.MEMPOOL.USER_AGENT === 'mempool') ? `mempool/v${backendInfo.getBackendInfo().version}` : `${config.MEMPOOL.USER_AGENT}` + }, + timeout: config.SOCKS5PROXY.ENABLED ? 30000 : 10000 + }; + + if (config.SOCKS5PROXY.ENABLED) { + const socksOptions = { + agentOptions: { + keepAlive: true, + }, + hostname: config.SOCKS5PROXY.HOST, + port: config.SOCKS5PROXY.PORT, + username: config.SOCKS5PROXY.USERNAME || 'circuit0', + password: config.SOCKS5PROXY.PASSWORD, + }; + + axiosOptions.httpsAgent = new SocksProxyAgent(socksOptions); + } + + const data: AxiosResponse = await axios.get(path, axiosOptions); + if (data.statusText === 'error' || !data.data) { + throw new Error(`${data.status}`); + } + return data.data; +} \ No newline at end of file diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 080de8480..078b85a03 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -401,7 +401,7 @@ class BlocksRepository { /** * Get average block health for all blocks for a single pool */ - public async $getAvgBlockHealthPerPoolId(poolId: number): Promise { + public async $getAvgBlockHealthPerPoolId(poolId: number): Promise { const params: any[] = []; const query = ` SELECT AVG(blocks_audits.match_rate) AS avg_match_rate @@ -413,8 +413,8 @@ class BlocksRepository { try { const [rows] = await DB.query(query, params); - if (!rows[0] || !rows[0].avg_match_rate) { - return 0; + if (!rows[0] || rows[0].avg_match_rate == null) { + return null; } return Math.round(rows[0].avg_match_rate * 100) / 100; } catch (e) { diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index aca3dbef8..963b9e8c2 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -3,7 +3,6 @@ import logger from '../../logger'; import channelsApi from '../../api/explorer/channels.api'; import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory'; import config from '../../config'; -import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface'; import { ILightningApi } from '../../api/lightning/lightning-api.interface'; import { $lookupNodeLocation } from './sync-tasks/node-locations'; import lightningApi from '../../api/lightning/lightning-api-factory'; @@ -269,7 +268,11 @@ class NetworkSyncService { } private async $scanForClosedChannels(): Promise { - if (this.closedChannelsScanBlock === blocks.getCurrentBlockHeight()) { + let currentBlockHeight = blocks.getCurrentBlockHeight(); + if (config.MEMPOOL.ENABLED === false) { // https://github.com/mempool/mempool/issues/3582 + currentBlockHeight = await bitcoinApi.$getBlockHeightTip(); + } + if (this.closedChannelsScanBlock === currentBlockHeight) { logger.debug(`We've already scan closed channels for this block, skipping.`); return; } @@ -305,7 +308,7 @@ class NetworkSyncService { } } - this.closedChannelsScanBlock = blocks.getCurrentBlockHeight(); + this.closedChannelsScanBlock = currentBlockHeight; logger.debug(`Closed channels scan completed at block ${this.closedChannelsScanBlock}`, logger.tags.ln); } catch (e) { logger.err(`$scanForClosedChannels() error: ${e instanceof Error ? e.message : e}`, logger.tags.ln); diff --git a/backend/src/tasks/price-updater.ts b/backend/src/tasks/price-updater.ts index 3b9dad30e..fafe2b913 100644 --- a/backend/src/tasks/price-updater.ts +++ b/backend/src/tasks/price-updater.ts @@ -153,6 +153,7 @@ class PriceUpdater { try { const p = 60 * 60 * 1000; // milliseconds in an hour const nowRounded = new Date(Math.round(new Date().getTime() / p) * p); // https://stackoverflow.com/a/28037042 + this.latestPrices.time = nowRounded.getTime() / 1000; await PricesRepository.$savePrices(nowRounded.getTime() / 1000, this.latestPrices); } catch (e) { this.lastRun = previousRun + 5 * 60; diff --git a/backend/src/utils/format.ts b/backend/src/utils/format.ts index a18ce1892..63dc07ae4 100644 --- a/backend/src/utils/format.ts +++ b/backend/src/utils/format.ts @@ -26,4 +26,70 @@ export function formatBytes(bytes: number, toUnit: string, skipUnit = false): st } return `${bytes.toFixed(2)}${skipUnit ? '' : ' ' + byteUnits[unitIndex]}`; +} + +// https://stackoverflow.com/a/64235212 +export function hex2bin(hex: string): string { + if (!hex) { + return ''; + } + + hex = hex.replace('0x', '').toLowerCase(); + let out = ''; + + for (const c of hex) { + switch (c) { + case '0': out += '0000'; break; + case '1': out += '0001'; break; + case '2': out += '0010'; break; + case '3': out += '0011'; break; + case '4': out += '0100'; break; + case '5': out += '0101'; break; + case '6': out += '0110'; break; + case '7': out += '0111'; break; + case '8': out += '1000'; break; + case '9': out += '1001'; break; + case 'a': out += '1010'; break; + case 'b': out += '1011'; break; + case 'c': out += '1100'; break; + case 'd': out += '1101'; break; + case 'e': out += '1110'; break; + case 'f': out += '1111'; break; + default: return ''; + } + } + return out; +} + +export function bin2hex(bin: string): string { + if (!bin) { + return ''; + } + + let out = ''; + + for (let i = 0; i < bin.length; i += 4) { + const c = bin.substring(i, i + 4); + switch (c) { + case '0000': out += '0'; break; + case '0001': out += '1'; break; + case '0010': out += '2'; break; + case '0011': out += '3'; break; + case '0100': out += '4'; break; + case '0101': out += '5'; break; + case '0110': out += '6'; break; + case '0111': out += '7'; break; + case '1000': out += '8'; break; + case '1001': out += '9'; break; + case '1010': out += 'a'; break; + case '1011': out += 'b'; break; + case '1100': out += 'c'; break; + case '1101': out += 'd'; break; + case '1110': out += 'e'; break; + case '1111': out += 'f'; break; + default: return ''; + } + } + + return out; } \ No newline at end of file diff --git a/contributors/bennyhodl.txt b/contributors/bennyhodl.txt new file mode 100644 index 000000000..c5f779de6 --- /dev/null +++ b/contributors/bennyhodl.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 June 28, 2023. + +Signed: bennyhodl diff --git a/contributors/pfoytik.txt b/contributors/pfoytik.txt new file mode 100644 index 000000000..f15f7cb33 --- /dev/null +++ b/contributors/pfoytik.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 June 15, 2023. + +Signed pfoytik diff --git a/contributors/secondl1ght.txt b/contributors/secondl1ght.txt new file mode 100644 index 000000000..a386a103a --- /dev/null +++ b/contributors/secondl1ght.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 June 14, 2023. + +Signed: secondl1ght diff --git a/docker/README.md b/docker/README.md index b669b37c8..d95bc7aee 100644 --- a/docker/README.md +++ b/docker/README.md @@ -144,8 +144,8 @@ Corresponding `docker-compose.yml` overrides: MEMPOOL_ADVANCED_GBT_AUDIT: "" MEMPOOL_ADVANCED_GBT_MEMPOOL: "" MEMPOOL_CPFP_INDEXING: "" - MAX_BLOCKS_BULK_QUERY: "" - DISK_CACHE_BLOCK_INTERVAL: "" + MEMPOOL_MAX_BLOCKS_BULK_QUERY: "" + MEMPOOL_DISK_CACHE_BLOCK_INTERVAL: "" ... ``` diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index 45f95a53e..2ff76d5dd 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -29,6 +29,8 @@ "CPFP_INDEXING": __MEMPOOL_CPFP_INDEXING__, "MAX_BLOCKS_BULK_QUERY": __MEMPOOL_MAX_BLOCKS_BULK_QUERY__, "DISK_CACHE_BLOCK_INTERVAL": __MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__, + "MAX_PUSH_TX_SIZE_WEIGHT": __MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__, + "ALLOW_UNREACHABLE": __MEMPOOL_ALLOW_UNREACHABLE__, "POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__", "POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__" }, @@ -125,5 +127,11 @@ "GEOLITE2_CITY": "__MAXMIND_GEOLITE2_CITY__", "GEOLITE2_ASN": "__MAXMIND_GEOLITE2_ASN__", "GEOIP2_ISP": "__MAXMIND_GEOIP2_ISP__" + }, + "REPLICATION": { + "ENABLED": __REPLICATION_ENABLED__, + "AUDIT": __REPLICATION_AUDIT__, + "AUDIT_START_HEIGHT": __REPLICATION_AUDIT_START_HEIGHT__, + "SERVERS": __REPLICATION_SERVERS__ } -} \ No newline at end of file +} diff --git a/docker/backend/start.sh b/docker/backend/start.sh index b746512a9..c34d804b4 100755 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -32,6 +32,9 @@ __MEMPOOL_RUST_GBT__=${MEMPOOL_RUST_GBT:=false} __MEMPOOL_CPFP_INDEXING__=${MEMPOOL_CPFP_INDEXING:=false} __MEMPOOL_MAX_BLOCKS_BULK_QUERY__=${MEMPOOL_MAX_BLOCKS_BULK_QUERY:=0} __MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__=${MEMPOOL_DISK_CACHE_BLOCK_INTERVAL:=6} +__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__=${MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT:=4000000} +__MEMPOOL_ALLOW_UNREACHABLE__=${MEMPOOL_ALLOW_UNREACHABLE:=true} + # CORE_RPC __CORE_RPC_HOST__=${CORE_RPC_HOST:=127.0.0.1} @@ -127,6 +130,12 @@ __MAXMIND_GEOLITE2_CITY__=${MAXMIND_GEOLITE2_CITY:="/backend/GeoIP/GeoLite2-City __MAXMIND_GEOLITE2_ASN__=${MAXMIND_GEOLITE2_ASN:="/backend/GeoIP/GeoLite2-ASN.mmdb"} __MAXMIND_GEOIP2_ISP__=${MAXMIND_GEOIP2_ISP:=""} +# REPLICATION +__REPLICATION_ENABLED__=${REPLICATION_ENABLED:=true} +__REPLICATION_AUDIT__=${REPLICATION_AUDIT:=true} +__REPLICATION_AUDIT_START_HEIGHT__=${REPLICATION_AUDIT_START_HEIGHT:=774000} +__REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]} + mkdir -p "${__MEMPOOL_CACHE_DIR__}" @@ -161,6 +170,8 @@ sed -i "s!__MEMPOOL_ADVANCED_GBT_AUDIT__!${__MEMPOOL_ADVANCED_GBT_AUDIT__}!g" me sed -i "s!__MEMPOOL_CPFP_INDEXING__!${__MEMPOOL_CPFP_INDEXING__}!g" mempool-config.json sed -i "s!__MEMPOOL_MAX_BLOCKS_BULK_QUERY__!${__MEMPOOL_MAX_BLOCKS_BULK_QUERY__}!g" mempool-config.json sed -i "s!__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__!${__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__}!g" mempool-config.json +sed -i "s!__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__!${__MEMPOOL_MAX_PUSH_TX_SIZE_WEIGHT__}!g" mempool-config.json +sed -i "s!__MEMPOOL_ALLOW_UNREACHABLE__!${__MEMPOOL_ALLOW_UNREACHABLE__}!g" mempool-config.json sed -i "s!__CORE_RPC_HOST__!${__CORE_RPC_HOST__}!g" mempool-config.json sed -i "s!__CORE_RPC_PORT__!${__CORE_RPC_PORT__}!g" mempool-config.json @@ -245,5 +256,10 @@ sed -i "s!__MAXMIND_GEOLITE2_CITY__!${__MAXMIND_GEOLITE2_CITY__}!g" mempool-conf sed -i "s!__MAXMIND_GEOLITE2_ASN__!${__MAXMIND_GEOLITE2_ASN__}!g" mempool-config.json sed -i "s!__MAXMIND_GEOIP2_ISP__!${__MAXMIND_GEOIP2_ISP__}!g" mempool-config.json +# REPLICATION +sed -i "s!__REPLICATION_ENABLED__!${__REPLICATION_ENABLED__}!g" mempool-config.json +sed -i "s!__REPLICATION_AUDIT__!${__REPLICATION_AUDIT__}!g" mempool-config.json +sed -i "s!__REPLICATION_AUDIT_START_HEIGHT__!${__REPLICATION_AUDIT_START_HEIGHT__}!g" mempool-config.json +sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.json node /backend/package/index.js diff --git a/frontend/README.md b/frontend/README.md index b626a23b9..8fc77a2b4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -17,7 +17,7 @@ Get the latest Mempool code: ``` git clone https://github.com/mempool/mempool -cd mempool +cd mempool/frontend ``` ### 2. Specify Website diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a9f8b7049..13dc6d1b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mempool-frontend", - "version": "2.6.0-dev", + "version": "3.0.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mempool-frontend", - "version": "2.6.0-dev", + "version": "3.0.0-dev", "license": "GNU Affero General Public License v3.0", "dependencies": { "@angular-devkit/build-angular": "^14.2.10", diff --git a/frontend/package.json b/frontend/package.json index c06fe74a7..d73fa8fa2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mempool-frontend", - "version": "2.6.0-dev", + "version": "3.0.0-dev", "description": "Bitcoin mempool visualizer and blockchain explorer backend", "license": "GNU Affero General Public License v3.0", "homepage": "https://mempool.space", @@ -119,4 +119,4 @@ "scarfSettings": { "enabled": false } -} \ No newline at end of file +} diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 7a9b53ed0..79a8e1c02 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -22,6 +22,7 @@ import { AssetsFeaturedComponent } from './components/assets/assets-featured/ass import { AssetsComponent } from './components/assets/assets.component'; import { AssetComponent } from './components/asset/asset.component'; import { AssetsNavComponent } from './components/assets/assets-nav/assets-nav.component'; +import { CalculatorComponent } from './components/calculator/calculator.component'; const browserWindow = window || {}; // @ts-ignore @@ -278,6 +279,10 @@ let routes: Routes = [ path: 'rbf', component: RbfList, }, + { + path: 'tools/calculator', + component: CalculatorComponent + }, { path: 'terms-of-service', component: TermsOfServiceComponent diff --git a/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.ts b/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.ts index fb30fc59f..47ac0d6db 100644 --- a/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.ts +++ b/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.ts @@ -112,7 +112,7 @@ export class BisqTransactionComponent implements OnInit, OnDestroy { this.error = error; }); - this.latestBlock$ = this.stateService.blocks$.pipe(map((([block]) => block))); + this.latestBlock$ = this.stateService.blocks$.pipe(map((blocks) => blocks[0])); this.stateService.bsqPrice$ .subscribe((bsqPrice) => { diff --git a/frontend/src/app/bisq/bisq-transfers/bisq-transfers.component.ts b/frontend/src/app/bisq/bisq-transfers/bisq-transfers.component.ts index 4346f15d3..a46cbf07f 100644 --- a/frontend/src/app/bisq/bisq-transfers/bisq-transfers.component.ts +++ b/frontend/src/app/bisq/bisq-transfers/bisq-transfers.component.ts @@ -27,7 +27,7 @@ export class BisqTransfersComponent implements OnInit, OnChanges { } ngOnInit() { - this.latestBlock$ = this.stateService.blocks$.pipe(map(([block]) => block)); + this.latestBlock$ = this.stateService.blocks$.pipe(map((blocks) => blocks[0])); } ngOnChanges() { diff --git a/frontend/src/app/components/address/address-preview.component.html b/frontend/src/app/components/address/address-preview.component.html index 1924d1a4c..392cc971e 100644 --- a/frontend/src/app/components/address/address-preview.component.html +++ b/frontend/src/app/components/address/address-preview.component.html @@ -3,7 +3,7 @@ Address
-
+

diff --git a/frontend/src/app/components/address/address-preview.component.scss b/frontend/src/app/components/address/address-preview.component.scss index afa8cb4b4..21e7faab5 100644 --- a/frontend/src/app/components/address/address-preview.component.scss +++ b/frontend/src/app/components/address/address-preview.component.scss @@ -20,6 +20,11 @@ margin-right: 15px; } +.table-col { + max-width: calc(100% - 470px); + overflow: hidden; +} + .table { font-size: 32px; margin-top: 48px; diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index 2ae9a962b..57439f983 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -207,7 +207,7 @@ export class AddressComponent implements OnInit, OnDestroy { } this.isLoadingTransactions = true; this.retryLoadMore = false; - this.electrsApiService.getAddressTransactionsFromHash$(this.address.address, this.lastTransactionTxId) + this.electrsApiService.getAddressTransactions$(this.address.address, this.lastTransactionTxId) .subscribe((transactions: Transaction[]) => { this.lastTransactionTxId = transactions[transactions.length - 1].txid; this.loadedConfirmedTxCount += transactions.length; @@ -217,6 +217,10 @@ export class AddressComponent implements OnInit, OnDestroy { (error) => { this.isLoadingTransactions = false; this.retryLoadMore = true; + // In the unlikely event of the txid wasn't found in the mempool anymore and we must reload the page. + if (error.status === 422) { + window.location.reload(); + } }); } diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html index 2a357843b..a625a0385 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html @@ -1,15 +1,17 @@ -
- -
-
-
not available
-
- +
+
+ +
+
+
not available
+
+ +
diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss index 58b53aebf..d30dd3305 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.scss @@ -6,8 +6,16 @@ display: flex; justify-content: center; align-items: center; + grid-column: 1/-1; } +.grid-align { + position: relative; + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, 75px); + justify-content: center; +} .block-overview-canvas { position: absolute; 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 15e41f1a7..49da16d55 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 @@ -6,6 +6,8 @@ import TxSprite from './tx-sprite'; import TxView from './tx-view'; import { Position } from './sprite-types'; import { Price } from '../../services/price.service'; +import { StateService } from '../../services/state.service'; +import { Subscription } from 'rxjs'; @Component({ selector: 'app-block-overview-graph', @@ -23,7 +25,6 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @Input() unavailable: boolean = false; @Input() auditHighlighting: boolean = false; @Input() blockConversion: Price; - @Input() pixelAlign: boolean = false; @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>(); @Output() txHoverEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter(); @@ -44,16 +45,25 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On scene: BlockScene; hoverTx: TxView | void; selectedTx: TxView | void; + highlightTx: TxView | void; mirrorTx: TxView | void; tooltipPosition: Position; readyNextFrame = false; + searchText: string; + searchSubscription: Subscription; + constructor( readonly ngZone: NgZone, readonly elRef: ElementRef, + private stateService: StateService, ) { this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); + this.searchSubscription = this.stateService.searchText$.subscribe((text) => { + this.searchText = text; + this.updateSearchHighlight(); + }); } ngAfterViewInit(): void { @@ -109,6 +119,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On this.scene.setup(transactions); this.readyNextFrame = true; this.start(); + this.updateSearchHighlight(); } } @@ -116,6 +127,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.scene) { this.scene.enter(transactions, direction); this.start(); + this.updateSearchHighlight(); } } @@ -123,6 +135,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.scene) { this.scene.exit(direction); this.start(); + this.updateSearchHighlight(); } } @@ -130,6 +143,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.scene) { this.scene.replace(transactions || [], direction, sort); this.start(); + this.updateSearchHighlight(); } } @@ -137,6 +151,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.scene) { this.scene.update(add, remove, change, direction, resetLayout); this.start(); + this.updateSearchHighlight(); } } @@ -203,7 +218,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } else { this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, - highlighting: this.auditHighlighting, pixelAlign: this.pixelAlign }); + highlighting: this.auditHighlighting }); this.start(); } } @@ -406,6 +421,19 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } } + updateSearchHighlight(): void { + if (this.highlightTx && this.highlightTx.txid !== this.searchText && this.scene) { + this.scene.setHighlight(this.highlightTx, false); + this.start(); + } else if (this.scene?.txs && this.searchText && this.searchText.length === 64) { + this.highlightTx = this.scene.txs[this.searchText]; + if (this.highlightTx) { + this.scene.setHighlight(this.highlightTx, true); + this.start(); + } + } + } + setHighlightingEnabled(enabled: boolean): void { if (this.scene) { this.scene.setHighlighting(enabled); 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 0cd5c9391..510803f03 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -15,7 +15,6 @@ export default class BlockScene { gridWidth: number; gridHeight: number; gridSize: number; - pixelAlign: boolean; vbytesPerUnit: number; unitPadding: number; unitWidth: number; @@ -24,24 +23,19 @@ export default class BlockScene { animateUntil = 0; dirty: boolean; - constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }: + constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }: { width: number, height: number, resolution: number, blockLimit: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, pixelAlign: boolean } + orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } ) { - this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }); + this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }); } resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void { this.width = width; this.height = height; this.gridSize = this.width / this.gridWidth; - if (this.pixelAlign) { - this.unitPadding = Math.max(1, Math.floor(this.gridSize / 2.5)); - this.unitWidth = this.gridSize - (this.unitPadding); - } else { - this.unitPadding = width / 500; - this.unitWidth = this.gridSize - (this.unitPadding * 2); - } + this.unitPadding = Math.max(1, Math.floor(this.gridSize / 5)); + this.unitWidth = this.gridSize - (this.unitPadding * 2); this.dirty = true; if (this.initialised && this.scene) { @@ -215,15 +209,18 @@ export default class BlockScene { this.animateUntil = Math.max(this.animateUntil, tx.setHover(value)); } - private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }: + setHighlight(tx: TxView, value: boolean): void { + this.animateUntil = Math.max(this.animateUntil, tx.setHighlight(value)); + } + + private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }: { width: number, height: number, resolution: number, blockLimit: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, pixelAlign: boolean } + orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } ): void { this.orientation = orientation; this.flip = flip; this.vertexArray = vertexArray; this.highlightingEnabled = highlighting; - this.pixelAlign = pixelAlign; this.scene = { count: 0, @@ -349,12 +346,7 @@ export default class BlockScene { private gridToScreen(position: Square | void): Square { if (position) { const slotSize = (position.s * this.gridSize); - let squareSize; - if (this.pixelAlign) { - squareSize = slotSize - (this.unitPadding); - } else { - squareSize = slotSize - (this.unitPadding * 2); - } + const squareSize = slotSize - (this.unitPadding * 2); // The grid is laid out notionally left-to-right, bottom-to-top, // so we rotate and/or flip the y axis to match the target configuration. @@ -430,7 +422,7 @@ export default class BlockScene { // calculates and returns the size of the tx in multiples of the grid size private txSize(tx: TxView): number { - const scale = Math.max(1, Math.round(Math.sqrt(tx.vsize / this.vbytesPerUnit))); + const scale = Math.max(1, Math.round(Math.sqrt(1.1 * tx.vsize / this.vbytesPerUnit))); return Math.min(this.gridWidth, Math.max(1, scale)); // bound between 1 and the max displayable size (just in case!) } diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index 7d3e0ee13..452bb38f5 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -7,6 +7,7 @@ import BlockScene from './block-scene'; const hoverTransitionTime = 300; const defaultHoverColor = hexToColor('1bd8f4'); +const defaultHighlightColor = hexToColor('800080'); const feeColors = mempoolFeeColors.map(hexToColor); const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); @@ -37,15 +38,17 @@ export default class TxView implements TransactionStripped { value: number; feerate: number; rate?: number; - status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'added' | 'censored' | 'selected' | 'fullrbf'; + status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf'; context?: 'projected' | 'actual'; scene?: BlockScene; initialised: boolean; vertexArray: FastVertexArray; hover: boolean; + highlight: boolean; sprite: TxSprite; hoverColor: Color | void; + highlightColor: Color | void; screenPosition: Square; gridPosition: Square | void; @@ -150,8 +153,40 @@ export default class TxView implements TransactionStripped { } else { this.hover = false; this.hoverColor = null; - if (this.sprite) { - this.sprite.resume(hoverTransitionTime); + if (this.highlight) { + this.setHighlight(true, this.highlightColor); + } else { + if (this.sprite) { + this.sprite.resume(hoverTransitionTime); + } + } + } + this.dirty = false; + return performance.now() + hoverTransitionTime; + } + + // Temporarily override the tx color + // returns minimum transition end time + setHighlight(highlightOn: boolean, color: Color | void = defaultHighlightColor): number { + if (highlightOn) { + this.highlight = true; + this.highlightColor = color; + + this.sprite.update({ + ...this.highlightColor, + duration: hoverTransitionTime, + adjust: false, + temp: true + }); + } else { + this.highlight = false; + this.highlightColor = null; + if (this.hover) { + this.setHover(true, this.hoverColor); + } else { + if (this.sprite) { + this.sprite.resume(hoverTransitionTime); + } } } this.dirty = false; @@ -175,6 +210,7 @@ export default class TxView implements TransactionStripped { case 'fullrbf': return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; case 'fresh': + case 'freshcpfp': return auditColors.missing; case 'added': return auditColors.added; diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html index 5ebd8fceb..59450326b 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html @@ -50,6 +50,7 @@ Marginal fee rate High sigop count Recently broadcasted + Recently CPFP'd Added Marginal fee rate Full RBF diff --git a/frontend/src/app/components/block/block-preview.component.html b/frontend/src/app/components/block/block-preview.component.html index 401f38487..0c33246a7 100644 --- a/frontend/src/app/components/block/block-preview.component.html +++ b/frontend/src/app/components/block/block-preview.component.html @@ -3,7 +3,7 @@ Block
-
+

@@ -71,7 +71,7 @@

-
+
@@ -110,7 +110,7 @@

Expected Block beta

- @@ -239,7 +239,7 @@

Actual Block

- diff --git a/frontend/src/app/components/block/block.component.scss b/frontend/src/app/components/block/block.component.scss index a15c876e6..c413b1fce 100644 --- a/frontend/src/app/components/block/block.component.scss +++ b/frontend/src/app/components/block/block.component.scss @@ -239,6 +239,7 @@ h1 { .nav-tabs { border-color: white; border-width: 1px; + margin-bottom: 1em; } .nav-tabs .nav-link { @@ -293,3 +294,7 @@ h1 { margin-top: 0.75rem; } } + +.graph-col { + flex-grow: 1.11; +} diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index ad008089d..4be6e3aff 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -14,6 +14,7 @@ import { ApiService } from '../../services/api.service'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; import { detectWebGL } from '../../shared/graphs.utils'; import { PriceService, Price } from '../../services/price.service'; +import { CacheService } from '../../services/cache.service'; @Component({ selector: 'app-block', @@ -72,6 +73,7 @@ export class BlockComponent implements OnInit, OnDestroy { auditSubscription: Subscription; keyNavigationSubscription: Subscription; blocksSubscription: Subscription; + cacheBlocksSubscription: Subscription; networkChangedSubscription: Subscription; queryParamsSubscription: Subscription; nextBlockSubscription: Subscription = undefined; @@ -99,6 +101,7 @@ export class BlockComponent implements OnInit, OnDestroy { private relativeUrlPipe: RelativeUrlPipe, private apiService: ApiService, private priceService: PriceService, + private cacheService: CacheService, ) { this.webGlEnabled = detectWebGL(); } @@ -128,19 +131,27 @@ export class BlockComponent implements OnInit, OnDestroy { map((indicators) => indicators['blocktxs-' + this.blockHash] !== undefined ? indicators['blocktxs-' + this.blockHash] : 0) ); + this.cacheBlocksSubscription = this.cacheService.loadedBlocks$.subscribe((block) => { + this.loadedCacheBlock(block); + }); + this.blocksSubscription = this.stateService.blocks$ - .subscribe(([block]) => { - this.latestBlock = block; - this.latestBlocks.unshift(block); - this.latestBlocks = this.latestBlocks.slice(0, this.stateService.env.KEEP_BLOCKS_AMOUNT); + .subscribe((blocks) => { + this.latestBlock = blocks[0]; + this.latestBlocks = blocks; this.setNextAndPreviousBlockLink(); - if (block.id === this.blockHash) { - this.block = block; - block.extras.minFee = this.getMinBlockFee(block); - block.extras.maxFee = this.getMaxBlockFee(block); - if (block?.extras?.reward != undefined) { - this.fees = block.extras.reward / 100000000 - this.blockSubsidy; + for (const block of blocks) { + if (block.id === this.blockHash) { + this.block = block; + block.extras.minFee = this.getMinBlockFee(block); + block.extras.maxFee = this.getMaxBlockFee(block); + if (block?.extras?.reward != undefined) { + this.fees = block.extras.reward / 100000000 - this.blockSubsidy; + } + } else if (block.height === this.block?.height) { + this.block.stale = true; + this.block.canonical = block.id; } } }); @@ -254,6 +265,13 @@ export class BlockComponent implements OnInit, OnDestroy { this.transactionsError = null; this.isLoadingOverview = true; this.overviewError = null; + + const cachedBlock = this.cacheService.getCachedBlock(block.height); + if (!cachedBlock) { + this.cacheService.loadBlock(block.height); + } else { + this.loadedCacheBlock(cachedBlock); + } }), throttleTime(300, asyncScheduler, { leading: true, trailing: true }), shareReplay(1) @@ -352,7 +370,11 @@ export class BlockComponent implements OnInit, OnDestroy { tx.status = 'found'; } else { if (isFresh[tx.txid]) { - tx.status = 'fresh'; + if (tx.rate - (tx.fee / tx.vsize) >= 0.1) { + tx.status = 'freshcpfp'; + } else { + tx.status = 'fresh'; + } } else if (isSigop[tx.txid]) { tx.status = 'sigop'; } else if (isFullRbf[tx.txid]) { @@ -459,6 +481,7 @@ export class BlockComponent implements OnInit, OnDestroy { this.auditSubscription?.unsubscribe(); this.keyNavigationSubscription?.unsubscribe(); this.blocksSubscription?.unsubscribe(); + this.cacheBlocksSubscription?.unsubscribe(); this.networkChangedSubscription?.unsubscribe(); this.queryParamsSubscription?.unsubscribe(); this.timeLtrSubscription?.unsubscribe(); @@ -679,4 +702,11 @@ export class BlockComponent implements OnInit, OnDestroy { } return 0; } + + loadedCacheBlock(block: BlockExtended): void { + if (this.block && block.height === this.block.height && block.id !== this.block.id) { + this.block.stale = true; + this.block.canonical = block.id; + } + } } \ No newline at end of file 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 5242c1fe5..245973885 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -36,11 +36,13 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { emptyBlocks: BlockExtended[] = this.mountEmptyBlocks(); markHeight: number; chainTip: number; + pendingMarkBlock: { animate: boolean, newBlockFromLeft: boolean }; blocksSubscription: Subscription; blockPageSubscription: Subscription; networkSubscription: Subscription; tabHiddenSubscription: Subscription; markBlockSubscription: Subscription; + txConfirmedSubscription: Subscription; loadingBlocks$: Observable; blockStyles = []; emptyBlockStyles = []; @@ -82,7 +84,6 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } ngOnInit() { - this.chainTip = this.stateService.latestBlockHeight; this.dynamicBlocksAmount = Math.min(8, this.stateService.env.KEEP_BLOCKS_AMOUNT); if (['', 'testnet', 'signet'].includes(this.stateService.network)) { @@ -104,31 +105,22 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.tabHiddenSubscription = this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); if (!this.static) { this.blocksSubscription = this.stateService.blocks$ - .subscribe(([block, txConfirmed]) => { - if (this.blocks.some((b) => b.height === block.height)) { + .subscribe((blocks) => { + if (!blocks?.length) { return; } + const latestHeight = blocks[0].height; + const animate = this.chainTip != null && latestHeight > this.chainTip; - if (this.blocks.length && block.height !== this.blocks[0].height + 1) { - this.blocks = []; - this.blocksFilled = false; + for (const block of blocks) { + block.extras.minFee = this.getMinBlockFee(block); + block.extras.maxFee = this.getMaxBlockFee(block); } - block.extras.minFee = this.getMinBlockFee(block); - block.extras.maxFee = this.getMaxBlockFee(block); - - this.blocks.unshift(block); - this.blocks = this.blocks.slice(0, this.dynamicBlocksAmount); - - if (txConfirmed && block.height > this.chainTip) { - this.markHeight = block.height; - this.moveArrowToPosition(true, true); - } else { - this.moveArrowToPosition(true, false); - } + this.blocks = blocks; this.blockStyles = []; - if (this.blocksFilled && block.height > this.chainTip) { + if (animate) { this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, i ? -this.blockOffset : -this.dividerBlockOffset))); setTimeout(() => { this.blockStyles = []; @@ -139,13 +131,23 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i))); } - if (this.blocks.length === this.dynamicBlocksAmount) { - this.blocksFilled = true; - } + this.chainTip = latestHeight; - this.chainTip = Math.max(this.chainTip, block.height); + if (this.pendingMarkBlock) { + this.moveArrowToPosition(this.pendingMarkBlock.animate, this.pendingMarkBlock.newBlockFromLeft); + this.pendingMarkBlock = null; + } this.cd.markForCheck(); }); + + this.txConfirmedSubscription = this.stateService.txConfirmed$.subscribe(([txid, block]) => { + if (txid) { + this.markHeight = block.height; + this.moveArrowToPosition(true, true); + } else { + this.moveArrowToPosition(true, false); + } + }) } else { this.blockPageSubscription = this.cacheService.loadedBlocks$.subscribe((block) => { if (block.height <= this.height && block.height > this.height - this.count) { @@ -164,9 +166,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.cd.markForCheck(); }); - if (this.static) { - this.updateStaticBlocks(); - } + if (this.static) { + this.updateStaticBlocks(); + } } ngOnChanges(changes: SimpleChanges): void { @@ -190,6 +192,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { if (this.blockPageSubscription) { this.blockPageSubscription.unsubscribe(); } + if (this.txConfirmedSubscription) { + this.txConfirmedSubscription.unsubscribe(); + } this.networkSubscription.unsubscribe(); this.tabHiddenSubscription.unsubscribe(); this.markBlockSubscription.unsubscribe(); @@ -202,6 +207,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.arrowVisible = false; return; } + if (this.chainTip == null) { + this.pendingMarkBlock = { animate, newBlockFromLeft }; + } const blockindex = this.blocks.findIndex((b) => b.height === this.markHeight); if (blockindex > -1) { if (!animate) { diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.ts b/frontend/src/app/components/blocks-list/blocks-list.component.ts index 324807628..2b54058e8 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.ts +++ b/frontend/src/app/components/blocks-list/blocks-list.component.ts @@ -82,12 +82,12 @@ export class BlocksList implements OnInit { ), this.stateService.blocks$ .pipe( - switchMap((block) => { - if (block[0].height <= this.lastBlockHeight) { + switchMap((blocks) => { + if (blocks[0].height <= this.lastBlockHeight) { return [null]; // Return an empty stream so the last pipe is not executed } - this.lastBlockHeight = block[0].height; - return [block]; + this.lastBlockHeight = blocks[0].height; + return blocks; }) ) ]) diff --git a/frontend/src/app/components/calculator/calculator.component.html b/frontend/src/app/components/calculator/calculator.component.html new file mode 100644 index 000000000..bdbfdd0cd --- /dev/null +++ b/frontend/src/app/components/calculator/calculator.component.html @@ -0,0 +1,69 @@ +
+
+

Calculator

+
+ + + +
+ +
+
+
+ {{ currency$ | async }} +
+ + +
+ +
+
+ BTC +
+ + +
+ +
+
+ sats +
+ + +
+ + +
+ +
+ +
+
+ โ‚ฟ + + sats +
+
+ +
+
+ +
+
+ +
+
+ Fiat price last updated +
+
+ + +
+ + +
+ Waiting for price feed... +
+
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/calculator/calculator.component.scss b/frontend/src/app/components/calculator/calculator.component.scss new file mode 100644 index 000000000..81f74f9ee --- /dev/null +++ b/frontend/src/app/components/calculator/calculator.component.scss @@ -0,0 +1,30 @@ +.input-group-text { + width: 75px; +} + +.bitcoin-satoshis-text { + font-size: 40px; +} + +.fiat-text { + font-size: 24px; +} + +.symbol { + font-style: italic; +} + +@media (max-width: 767.98px) { + .bitcoin-satoshis-text { + font-size: 30px; + } +} + +.sats { + font-size: 20px; + margin-left: 5px; +} + +.row { + margin: auto; +} diff --git a/frontend/src/app/components/calculator/calculator.component.ts b/frontend/src/app/components/calculator/calculator.component.ts new file mode 100644 index 000000000..a6f10c049 --- /dev/null +++ b/frontend/src/app/components/calculator/calculator.component.ts @@ -0,0 +1,137 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { combineLatest, Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { StateService } from '../../services/state.service'; +import { WebsocketService } from '../../services/websocket.service'; + +@Component({ + selector: 'app-calculator', + templateUrl: './calculator.component.html', + styleUrls: ['./calculator.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CalculatorComponent implements OnInit { + satoshis = 10000; + form: FormGroup; + + currency$ = this.stateService.fiatCurrency$; + price$: Observable; + lastFiatPrice$: Observable; + + constructor( + private stateService: StateService, + private formBuilder: FormBuilder, + private websocketService: WebsocketService, + ) { } + + ngOnInit(): void { + this.form = this.formBuilder.group({ + fiat: [0], + bitcoin: [0], + satoshis: [0], + }); + + this.lastFiatPrice$ = this.stateService.conversions$.asObservable() + .pipe( + map((conversions) => conversions.time) + ); + + let currency; + this.price$ = this.currency$.pipe( + switchMap((result) => { + currency = result; + return this.stateService.conversions$.asObservable(); + }), + map((conversions) => { + return conversions[currency]; + }) + ); + + combineLatest([ + this.price$, + this.form.get('fiat').valueChanges + ]).subscribe(([price, value]) => { + const rate = (value / price).toFixed(8); + const satsRate = Math.round(value / price * 100_000_000); + if (isNaN(value)) { + return; + } + this.form.get('bitcoin').setValue(rate, { emitEvent: false }); + this.form.get('satoshis').setValue(satsRate, { emitEvent: false } ); + }); + + combineLatest([ + this.price$, + this.form.get('bitcoin').valueChanges + ]).subscribe(([price, value]) => { + const rate = parseFloat((value * price).toFixed(8)); + if (isNaN(value)) { + return; + } + this.form.get('fiat').setValue(rate, { emitEvent: false } ); + this.form.get('satoshis').setValue(Math.round(value * 100_000_000), { emitEvent: false } ); + }); + + combineLatest([ + this.price$, + this.form.get('satoshis').valueChanges + ]).subscribe(([price, value]) => { + const rate = parseFloat((value / 100_000_000 * price).toFixed(8)); + const bitcoinRate = (value / 100_000_000).toFixed(8); + if (isNaN(value)) { + return; + } + this.form.get('fiat').setValue(rate, { emitEvent: false } ); + this.form.get('bitcoin').setValue(bitcoinRate, { emitEvent: false }); + }); + + } + + transformInput(name: string): void { + const formControl = this.form.get(name); + if (!formControl.value) { + return formControl.setValue('', {emitEvent: false}); + } + let value = formControl.value.replace(',', '.').replace(/[^0-9.]/g, ''); + if (value === '.') { + value = '0'; + } + let sanitizedValue = this.removeExtraDots(value); + if (name === 'bitcoin' && this.countDecimals(sanitizedValue) > 8) { + sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, 8); + } + if (sanitizedValue === '') { + sanitizedValue = '0'; + } + if (name === 'satoshis') { + sanitizedValue = parseFloat(sanitizedValue).toFixed(0); + } + formControl.setValue(sanitizedValue, {emitEvent: true}); + } + + removeExtraDots(str: string): string { + const [beforeDot, afterDot] = str.split('.', 2); + if (afterDot === undefined) { + return str; + } + const afterDotReplaced = afterDot.replace(/\./g, ''); + return `${beforeDot}.${afterDotReplaced}`; + } + + countDecimals(numberString: string): number { + const decimalPos = numberString.indexOf('.'); + if (decimalPos === -1) return 0; + return numberString.length - decimalPos - 1; + } + + toFixedWithoutRounding(numStr: string, fixed: number): string { + const re = new RegExp(`^-?\\d+(?:.\\d{0,${(fixed || -1)}})?`); + const result = numStr.match(re); + return result ? result[0] : numStr; + } + + selectAll(event): void { + event.target.select(); + } +} diff --git a/frontend/src/app/components/change/change.component.html b/frontend/src/app/components/change/change.component.html index 117a0c534..ffc00bf5f 100644 --- a/frontend/src/app/components/change/change.component.html +++ b/frontend/src/app/components/change/change.component.html @@ -1,3 +1,3 @@ - {{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}% + ‎{{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}% diff --git a/frontend/src/app/components/clock-face/clock-face.component.ts b/frontend/src/app/components/clock-face/clock-face.component.ts index c2c946b74..63d87c436 100644 --- a/frontend/src/app/components/clock-face/clock-face.component.ts +++ b/frontend/src/app/components/clock-face/clock-face.component.ts @@ -39,13 +39,10 @@ export class ClockFaceComponent implements OnInit, OnChanges, OnDestroy { }) ).subscribe(); this.blocksSubscription = this.stateService.blocks$ - .subscribe(([block]) => { - if (block) { - this.blockTimes.push([block.height, new Date(block.timestamp * 1000)]); - // using block-reported times, so ensure they are sorted chronologically - this.blockTimes = this.blockTimes.sort((a, b) => a[1].getTime() - b[1].getTime()); - this.updateSegments(); - } + .subscribe((blocks) => { + this.blockTimes = blocks.map(block => [block.height, new Date(block.timestamp * 1000)]); + this.blockTimes = this.blockTimes.sort((a, b) => a[1].getTime() - b[1].getTime()); + this.updateSegments(); }); } diff --git a/frontend/src/app/components/clock/clock.component.html b/frontend/src/app/components/clock/clock.component.html index 373653b7e..bdddef730 100644 --- a/frontend/src/app/components/clock/clock.component.html +++ b/frontend/src/app/components/clock/clock.component.html @@ -25,7 +25,7 @@
- +
diff --git a/frontend/src/app/components/clock/clock.component.ts b/frontend/src/app/components/clock/clock.component.ts index b1a9d2159..7ae38583a 100644 --- a/frontend/src/app/components/clock/clock.component.ts +++ b/frontend/src/app/components/clock/clock.component.ts @@ -60,14 +60,11 @@ export class ClockComponent implements OnInit { this.websocketService.want(['blocks', 'stats', 'mempool-blocks']); this.blocksSubscription = this.stateService.blocks$ - .subscribe(([block]) => { - if (block) { - this.blocks.unshift(block); - this.blocks = this.blocks.slice(0, 16); - if (this.blocks[this.blockIndex]) { - this.blockStyle = this.getStyleForBlock(this.blocks[this.blockIndex]); - this.cd.markForCheck(); - } + .subscribe((blocks) => { + this.blocks = blocks.slice(0, 16); + if (this.blocks[this.blockIndex]) { + this.blockStyle = this.getStyleForBlock(this.blocks[this.blockIndex]); + this.cd.markForCheck(); } }); diff --git a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts index fbf31f238..c23d7d4b9 100644 --- a/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts +++ b/frontend/src/app/components/difficulty-mining/difficulty-mining.component.ts @@ -38,11 +38,12 @@ export class DifficultyMiningComponent implements OnInit { ngOnInit(): void { this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; this.difficultyEpoch$ = combineLatest([ - this.stateService.blocks$.pipe(map(([block]) => block)), + this.stateService.blocks$, this.stateService.difficultyAdjustment$, ]) .pipe( - map(([block, da]) => { + map(([blocks, da]) => { + const maxHeight = blocks.reduce((max, block) => Math.max(max, block.height), 0); let colorAdjustments = '#ffffff66'; if (da.difficultyChange > 0) { colorAdjustments = '#3bcc49'; @@ -63,7 +64,7 @@ export class DifficultyMiningComponent implements OnInit { colorPreviousAdjustments = '#ffffff66'; } - const blocksUntilHalving = 210000 - (block.height % 210000); + const blocksUntilHalving = 210000 - (maxHeight % 210000); const timeUntilHalving = new Date().getTime() + (blocksUntilHalving * 600000); const data = { diff --git a/frontend/src/app/components/difficulty/difficulty.component.ts b/frontend/src/app/components/difficulty/difficulty.component.ts index b246a14fe..d3983c939 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.ts +++ b/frontend/src/app/components/difficulty/difficulty.component.ts @@ -67,11 +67,12 @@ export class DifficultyComponent implements OnInit { ngOnInit(): void { this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; this.difficultyEpoch$ = combineLatest([ - this.stateService.blocks$.pipe(map(([block]) => block)), + this.stateService.blocks$, this.stateService.difficultyAdjustment$, ]) .pipe( - map(([block, da]) => { + map(([blocks, da]) => { + const maxHeight = blocks.reduce((max, block) => Math.max(max, block.height), 0); let colorAdjustments = '#ffffff66'; if (da.difficultyChange > 0) { colorAdjustments = '#3bcc49'; @@ -92,7 +93,7 @@ export class DifficultyComponent implements OnInit { colorPreviousAdjustments = '#ffffff66'; } - const blocksUntilHalving = 210000 - (block.height % 210000); + const blocksUntilHalving = 210000 - (maxHeight % 210000); const timeUntilHalving = new Date().getTime() + (blocksUntilHalving * 600000); const newEpochStart = Math.floor(this.stateService.latestBlockHeight / EPOCH_BLOCK_LENGTH) * EPOCH_BLOCK_LENGTH; const newExpectedHeight = Math.floor(newEpochStart + da.expectedBlocks); diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts index fcff0dddb..62cc71ca6 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts @@ -109,6 +109,14 @@ export class HashrateChartComponent implements OnInit { tap((response: any) => { const data = response.body; + // always include the latest difficulty + if (data.difficulty.length && data.difficulty[data.difficulty.length - 1].difficulty !== data.currentDifficulty) { + data.difficulty.push({ + timestamp: Date.now() / 1000, + difficulty: data.currentDifficulty + }); + } + // We generate duplicated data point so the tooltip works nicely const diffFixed = []; let diffIndex = 1; @@ -122,6 +130,7 @@ export class HashrateChartComponent implements OnInit { }); ++hashIndex; } + diffIndex++; break; } @@ -137,6 +146,14 @@ export class HashrateChartComponent implements OnInit { ++diffIndex; } + while (diffIndex <= data.difficulty.length) { + diffFixed.push({ + timestamp: data.difficulty[diffIndex - 1].time, + difficulty: data.difficulty[diffIndex - 1].difficulty + }); + diffIndex++; + } + let maResolution = 15; const hashrateMa = []; for (let i = maResolution - 1; i < data.hashrates.length; ++i) { diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html index 37c82afad..503f2e38d 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html @@ -1,10 +1,9 @@ diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index 540046e13..30632a862 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -16,7 +16,6 @@ import { Router } from '@angular/router'; }) export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { @Input() index: number; - @Input() pixelAlign: boolean = false; @Output() txPreviewEvent = new EventEmitter(); @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index bc3633be0..3ec240b78 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -124,7 +124,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { ) .pipe( switchMap(() => combineLatest([ - this.stateService.blocks$.pipe(map(([block]) => block)), + this.stateService.blocks$.pipe(map((blocks) => blocks[0])), this.stateService.mempoolBlocks$ .pipe( map((mempoolBlocks) => { @@ -186,8 +186,11 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.cd.markForCheck(); }); - this.blockSubscription = this.stateService.blocks$ - .subscribe(([block]) => { + this.blockSubscription = this.stateService.blocks$.pipe(map((blocks) => blocks[0])) + .subscribe((block) => { + if (!block) { + return; + } if (this.chainTip === -1) { this.animateEntry = block.height === this.stateService.latestBlockHeight; } else { @@ -221,8 +224,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.router.navigate([this.relativeUrlPipe.transform('mempool-block/'), this.markIndex - 1]); } else { this.stateService.blocks$ - .pipe(take(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT)) - .subscribe(([block]) => { + .pipe(map((blocks) => blocks[0])) + .subscribe((block) => { if (this.stateService.latestBlockHeight === block.height) { this.router.navigate([this.relativeUrlPipe.transform('/block/'), block.id], { state: { data: { block } }}); } @@ -297,7 +300,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { while (blocks.length > blocksAmount) { const block = blocks.pop(); if (!this.count) { - const lastBlock = blocks[blocks.length - 1]; + const lastBlock = blocks[0]; lastBlock.blockSize += block.blockSize; lastBlock.blockVSize += block.blockVSize; lastBlock.nTx += block.nTx; @@ -308,7 +311,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { } } if (blocks.length) { - blocks[blocks.length - 1].isStack = blocks[blocks.length - 1].blockVSize > this.stateService.blockVSize; + blocks[0].isStack = blocks[0].blockVSize > this.stateService.blockVSize; } return blocks; } diff --git a/frontend/src/app/components/pool/pool.component.ts b/frontend/src/app/components/pool/pool.component.ts index 85fd028ef..f2fc79ff2 100644 --- a/frontend/src/app/components/pool/pool.component.ts +++ b/frontend/src/app/components/pool/pool.component.ts @@ -68,7 +68,7 @@ export class PoolComponent implements OnInit { return this.apiService.getPoolStats$(slug); }), tap(() => { - this.loadMoreSubject.next(this.blocks[this.blocks.length - 1]?.height); + this.loadMoreSubject.next(this.blocks[0]?.height); }), map((poolStats) => { this.seoService.setTitle(poolStats.pool.name); diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.html index 68f8a1caf..540da7480 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.html +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.html @@ -32,6 +32,7 @@
+ + + + + +
Status + Full RBF RBF RBF Mined diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.ts index b9da63c86..fc3748f32 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.ts +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline-tooltip.component.ts @@ -1,5 +1,5 @@ import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core'; -import { RbfInfo } from '../../interfaces/node-api.interface'; +import { RbfTree } from '../../interfaces/node-api.interface'; @Component({ selector: 'app-rbf-timeline-tooltip', @@ -7,7 +7,7 @@ import { RbfInfo } from '../../interfaces/node-api.interface'; styleUrls: ['./rbf-timeline-tooltip.component.scss'], }) export class RbfTimelineTooltipComponent implements OnChanges { - @Input() rbfInfo: RbfInfo | void; + @Input() rbfInfo: RbfTree | null; @Input() cursorPosition: { x: number, y: number }; tooltipPosition = null; diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html index ce5a9678f..a2012d45f 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html @@ -15,14 +15,15 @@
- +
-
+
+
-
-
+
+
-
+
diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss index 3745360a5..be7aef2d6 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss @@ -83,15 +83,26 @@ transform: translateY(-50%); background: #105fb0; border-radius: 5px; + + &.left { + right: 50%; + } + &.right { + left: 50%; + } + + &.fullrbf { + background: #1bd8f4; + } } &.first-node { - .track { - left: 50%; + .track.left { + display: none; } } &:last-child { - .track { - right: 50%; + .track.right { + display: none; } } } @@ -177,11 +188,17 @@ height: 108px; bottom: 50%; border-right: solid 10px #105fb0; + &.fullrbf { + border-right: solid 10px #1bd8f4; + } } .corner { border-bottom: solid 10px #105fb0; border-bottom-right-radius: 10px; + &.fullrbf { + border-bottom: solid 10px #1bd8f4; + } } } } diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts index f02e8ca35..474da7326 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts @@ -1,15 +1,20 @@ import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID, HostListener } from '@angular/core'; import { Router } from '@angular/router'; -import { RbfInfo, RbfTree } from '../../interfaces/node-api.interface'; +import { RbfTree, RbfTransaction } from '../../interfaces/node-api.interface'; import { StateService } from '../../services/state.service'; import { ApiService } from '../../services/api.service'; type Connector = 'pipe' | 'corner'; interface TimelineCell { - replacement?: RbfInfo, + replacement?: RbfTree, connector?: Connector, first?: boolean, + fullRbf?: boolean, +} + +function isTimelineCell(val: RbfTree | TimelineCell): boolean { + return !val || !('tx' in val); } @Component({ @@ -22,7 +27,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges { @Input() txid: string; rows: TimelineCell[][] = []; - hoverInfo: RbfInfo | void = null; + hoverInfo: RbfTree | null = null; tooltipPosition = null; dir: 'rtl' | 'ltr' = 'ltr'; @@ -53,13 +58,27 @@ export class RbfTimelineComponent implements OnInit, OnChanges { buildTimelines(tree: RbfTree): TimelineCell[][] { if (!tree) return []; + this.flagFullRbf(tree); const split = this.splitTimelines(tree); const timelines = this.prepareTimelines(split); return this.connectTimelines(timelines); } + // sets the fullRbf flag on each transaction in the tree + flagFullRbf(tree: RbfTree): void { + let fullRbf = false; + for (const replaced of tree.replaces) { + if (!replaced.tx.rbf) { + fullRbf = true; + } + replaced.replacedBy = tree.tx; + this.flagFullRbf(replaced); + } + tree.tx.fullRbf = fullRbf; + } + // splits a tree into N leaf-to-root paths - splitTimelines(tree: RbfTree, tail: RbfInfo[] = []): RbfInfo[][] { + splitTimelines(tree: RbfTree, tail: RbfTree[] = []): RbfTree[][] { const replacements = [...tail, tree]; if (tree.replaces.length) { return [].concat(...tree.replaces.map(subtree => this.splitTimelines(subtree, replacements))); @@ -70,7 +89,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges { // merges separate leaf-to-root paths into a coherent forking timeline // represented as a 2D array of Rbf events - prepareTimelines(lines: RbfInfo[][]): RbfInfo[][] { + prepareTimelines(lines: RbfTree[][]): (RbfTree | TimelineCell)[][] { lines.sort((a, b) => b.length - a.length); const rows = lines.map(() => []); @@ -85,7 +104,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges { let emptyCount = 0; const nextGroups = []; for (const group of lineGroups) { - const toMerge: { [txid: string]: RbfInfo[][] } = {}; + const toMerge: { [txid: string]: RbfTree[][] } = {}; let emptyInGroup = 0; let first = true; for (const line of group) { @@ -97,7 +116,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges { } else { // substitute duplicates with empty cells // (we'll fill these in with connecting lines later) - rows[index].unshift(null); + rows[index].unshift({ connector: true, replacement: head }); } // group the tails of the remaining lines for the next iteration if (line.length) { @@ -127,7 +146,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges { } // annotates a 2D timeline array with info needed to draw connecting lines for multi-replacements - connectTimelines(timelines: RbfInfo[][]): TimelineCell[][] { + connectTimelines(timelines: (RbfTree | TimelineCell)[][]): TimelineCell[][] { const rows: TimelineCell[][] = []; timelines.forEach((lines, row) => { rows.push([]); @@ -135,11 +154,12 @@ export class RbfTimelineComponent implements OnInit, OnChanges { let finished = false; lines.forEach((replacement, column) => { const cell: TimelineCell = {}; - if (replacement) { - cell.replacement = replacement; + if (!isTimelineCell(replacement)) { + cell.replacement = replacement as RbfTree; + cell.fullRbf = (replacement as RbfTree).replacedBy?.fullRbf; } rows[row].push(cell); - if (replacement) { + if (!isTimelineCell(replacement)) { if (!started) { cell.first = true; started = true; @@ -153,11 +173,13 @@ export class RbfTimelineComponent implements OnInit, OnChanges { matched = true; } else if (i === row) { rows[i][column] = { - connector: 'corner' + connector: 'corner', + fullRbf: (replacement as TimelineCell).replacement.tx.fullRbf, }; } else if (nextCell.connector !== 'corner') { rows[i][column] = { - connector: 'pipe' + connector: 'pipe', + fullRbf: (replacement as TimelineCell).replacement.tx.fullRbf, }; } } diff --git a/frontend/src/app/components/reward-stats/reward-stats.component.ts b/frontend/src/app/components/reward-stats/reward-stats.component.ts index 30bf26488..5aac641b0 100644 --- a/frontend/src/app/components/reward-stats/reward-stats.component.ts +++ b/frontend/src/app/components/reward-stats/reward-stats.component.ts @@ -29,11 +29,12 @@ export class RewardStatsComponent implements OnInit { // Or when we receive a newer block, newer than the latest reward stats api call this.stateService.blocks$ .pipe( - switchMap((block) => { - if (block[0].height <= this.lastBlockHeight) { + switchMap((blocks) => { + const maxHeight = blocks.reduce((max, block) => Math.max(max, block.height), 0); + if (maxHeight <= this.lastBlockHeight) { return []; // Return an empty stream so the last pipe is not executed } - this.lastBlockHeight = block[0].height; + this.lastBlockHeight = maxHeight; return this.apiService.getRewardStats$(); }) ) diff --git a/frontend/src/app/components/search-form/search-form.component.ts b/frontend/src/app/components/search-form/search-form.component.ts index 422cb2f45..ab42fe1f7 100644 --- a/frontend/src/app/components/search-form/search-form.component.ts +++ b/frontend/src/app/components/search-form/search-form.component.ts @@ -80,6 +80,9 @@ export class SearchFormComponent implements OnInit { } return text.trim(); }), + tap((text) => { + this.stateService.searchText$.next(text); + }), distinctUntilChanged(), ); diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index 22d3d6350..33770bb24 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -2,6 +2,7 @@ import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild, Inpu import { Subscription } from 'rxjs'; import { MarkBlockState, StateService } from '../../services/state.service'; import { specialBlocks } from '../../app.constants'; +import { BlockExtended } from '../../interfaces/node-api.interface'; @Component({ selector: 'app-start', @@ -55,8 +56,8 @@ export class StartComponent implements OnInit, OnDestroy { ngOnInit() { this.firstPageWidth = 40 + (this.blockWidth * this.dynamicBlocksAmount); - this.blockCounterSubscription = this.stateService.blocks$.subscribe(() => { - this.blockCount++; + this.blockCounterSubscription = this.stateService.blocks$.subscribe((blocks) => { + this.blockCount = blocks.length; this.dynamicBlocksAmount = Math.min(this.blockCount, this.stateService.env.KEEP_BLOCKS_AMOUNT, 8); this.firstPageWidth = 40 + (this.blockWidth * this.dynamicBlocksAmount); if (this.blockCount <= Math.min(8, this.stateService.env.KEEP_BLOCKS_AMOUNT)) { @@ -110,9 +111,12 @@ export class StartComponent implements OnInit, OnDestroy { } }); this.stateService.blocks$ - .subscribe((blocks: any) => { + .subscribe((blocks: BlockExtended[]) => { this.countdown = 0; const block = blocks[0]; + if (!block) { + return; + } for (const sb in specialBlocks) { if (specialBlocks[sb].networks.includes(this.stateService.network || 'mainnet')) { diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 25707b007..d4cd6913d 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -19,6 +19,7 @@
- +
@@ -451,7 +452,7 @@ - +

Transaction not found.

diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index bbf679dcf..0faa328c0 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -12,7 +12,7 @@ import { tap } from 'rxjs/operators'; import { Transaction } from '../../interfaces/electrs.interface'; -import { of, merge, Subscription, Observable, Subject, timer, from, throwError } from 'rxjs'; +import { of, merge, Subscription, Observable, Subject, from, throwError } from 'rxjs'; import { StateService } from '../../services/state.service'; import { CacheService } from '../../services/cache.service'; import { WebsocketService } from '../../services/websocket.service'; @@ -39,6 +39,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { isLoadingTx = true; error: any = undefined; errorUnblinded: any = undefined; + loadingCachedTx = false; waitingForTransaction = false; latestBlock: BlockExtended; transactionTime = -1; @@ -49,10 +50,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { txReplacedSubscription: Subscription; txRbfInfoSubscription: Subscription; mempoolPositionSubscription: Subscription; - blocksSubscription: Subscription; queryParamsSubscription: Subscription; urlFragmentSubscription: Subscription; mempoolBlocksSubscription: Subscription; + blocksSubscription: Subscription; fragmentParams: URLSearchParams; rbfTransaction: undefined | Transaction; replaced: boolean = false; @@ -131,6 +132,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.outputIndex = (!isNaN(vout) && vout >= 0) ? vout : null; }); + this.blocksSubscription = this.stateService.blocks$.subscribe((blocks) => { + this.latestBlock = blocks[0]; + }); + this.fetchCpfpSubscription = this.fetchCpfp$ .pipe( switchMap((txId) => @@ -199,6 +204,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.fetchCachedTxSubscription = this.fetchCachedTx$ .pipe( + tap(() => { + this.loadingCachedTx = true; + }), switchMap((txId) => this.apiService .getRbfCachedTx$(txId) @@ -207,6 +215,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { return of(null); }) ).subscribe((tx) => { + this.loadingCachedTx = false; if (!tx) { return; } @@ -338,6 +347,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.tx.feePerVsize = tx.fee / (tx.weight / 4); this.isLoadingTx = false; this.error = undefined; + this.loadingCachedTx = false; this.waitingForTransaction = false; this.websocketService.startTrackTransaction(tx.txid); this.graphExpanded = false; @@ -391,9 +401,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } ); - this.blocksSubscription = this.stateService.blocks$.subscribe(([block, txConfirmed]) => { - this.latestBlock = block; - + this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => { if (txConfirmed && this.tx && !this.tx.status.confirmed && txConfirmed === this.tx.txid) { this.tx.status = { confirmed: true, @@ -409,6 +417,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.txReplacedSubscription = this.stateService.txReplaced$.subscribe((rbfTransaction) => { if (!this.tx) { this.error = new Error(); + this.loadingCachedTx = false; this.waitingForTransaction = false; } this.rbfTransaction = rbfTransaction; @@ -593,13 +602,13 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.fetchCachedTxSubscription.unsubscribe(); this.txReplacedSubscription.unsubscribe(); this.txRbfInfoSubscription.unsubscribe(); - this.blocksSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe(); this.flowPrefSubscription.unsubscribe(); this.urlFragmentSubscription.unsubscribe(); this.mempoolBlocksSubscription.unsubscribe(); this.mempoolPositionSubscription.unsubscribe(); this.mempoolBlocksSubscription.unsubscribe(); + this.blocksSubscription.unsubscribe(); this.leaveTransaction(); } } diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 53ddb449c..c49ff0e3c 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -56,7 +56,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { ) { } ngOnInit(): void { - this.latestBlock$ = this.stateService.blocks$.pipe(map(([block]) => block)); + this.latestBlock$ = this.stateService.blocks$.pipe(map((blocks) => blocks[0])); this.stateService.networkChanged$.subscribe((network) => this.network = network); if (this.network === 'liquid' || this.network === 'liquidtestnet') { diff --git a/frontend/src/app/dashboard/dashboard.component.html b/frontend/src/app/dashboard/dashboard.component.html index 620678a28..3faef5a83 100644 --- a/frontend/src/app/dashboard/dashboard.component.html +++ b/frontend/src/app/dashboard/dashboard.component.html @@ -75,36 +75,31 @@
- -
Latest blocks
+
+
Latest replacements
 
- +
- - - - - + + + + - - - - + - - + + diff --git a/frontend/src/app/dashboard/dashboard.component.scss b/frontend/src/app/dashboard/dashboard.component.scss index eb466fc16..f1e835d9c 100644 --- a/frontend/src/app/dashboard/dashboard.component.scss +++ b/frontend/src/app/dashboard/dashboard.component.scss @@ -175,39 +175,43 @@ height: 18px; } -.lastest-blocks-table { +.lastest-replacements-table { width: 100%; text-align: left; + table-layout:fixed; tr, td, th { border: 0px; - padding-top: 0.65rem !important; - padding-bottom: 0.7rem !important; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; } - .table-cell-height { - width: 15%; + td { + overflow:hidden; + width: 25%; } - .table-cell-mined { - width: 35%; - text-align: left; + .table-cell-txid { + width: 25%; + text-align: start; } - .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) { + .table-cell-old-fee { + width: 25%; + text-align: end; + + @media(max-width: 1080px) { display: none; } - @media (min-width: 992px) { - display: table-cell; + } + .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; } } } diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index 7e4645fe0..b1bc35eca 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { combineLatest, merge, Observable, of, Subscription } from 'rxjs'; -import { filter, map, scan, share, switchMap, tap } from 'rxjs/operators'; -import { BlockExtended, OptimizedMempoolStats } from '../interfaces/node-api.interface'; -import { MempoolInfo, TransactionStripped } from '../interfaces/websocket.interface'; +import { filter, map, scan, share, switchMap } from 'rxjs/operators'; +import { BlockExtended, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; +import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.interface'; import { ApiService } from '../services/api.service'; import { StateService } from '../services/state.service'; import { WebsocketService } from '../services/websocket.service'; @@ -38,8 +38,8 @@ export class DashboardComponent implements OnInit, OnDestroy { mempoolInfoData$: Observable; mempoolLoadingStatus$: Observable; vBytesPerSecondLimit = 1667; - blocks$: Observable; transactions$: Observable; + replacements$: Observable; latestBlockHeight: number; mempoolTransactionsWeightPerSecondData: any; mempoolStats$: Observable; @@ -58,12 +58,14 @@ export class DashboardComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.currencySubscription.unsubscribe(); + this.websocketService.stopTrackRbfSummary(); } ngOnInit(): void { this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; this.seoService.resetTitle(); 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( @@ -130,30 +132,6 @@ export class DashboardComponent implements OnInit, OnDestroy { }), ); - this.blocks$ = this.stateService.blocks$ - .pipe( - tap(([block]) => { - this.latestBlockHeight = block.height; - }), - scan((acc, [block]) => { - if (acc.find((b) => b.height == block.height)) { - return acc; - } - acc.unshift(block); - acc = acc.slice(0, 6); - - if (this.stateService.env.MINING_DASHBOARD === true) { - for (const block of acc) { - // @ts-ignore: Need to add an extra field for the template - block.extras.pool.logo = `/resources/mining-pools/` + - block.extras.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; - } - } - - return acc; - }, []), - ); - this.transactions$ = this.stateService.transactions$ .pipe( scan((acc, tx) => { @@ -166,6 +144,8 @@ export class DashboardComponent implements OnInit, OnDestroy { }, []), ); + this.replacements$ = this.stateService.rbfLatestSummary$; + this.mempoolStats$ = this.stateService.connectionState$ .pipe( filter((state) => state === 2), @@ -226,4 +206,16 @@ export class DashboardComponent implements OnInit, OnDestroy { trackByBlock(index: number, block: BlockExtended) { return block.height; } + + checkFullRbf(tree: RbfTree): void { + let fullRbf = false; + for (const replaced of tree.replaces) { + if (!replaced.tx.rbf) { + fullRbf = true; + } + replaced.replacedBy = tree.tx; + this.checkFullRbf(replaced); + } + tree.tx.fullRbf = fullRbf; + } } diff --git a/frontend/src/app/fiat/fiat.component.html b/frontend/src/app/fiat/fiat.component.html index afee30d27..ebf59ffb1 100644 --- a/frontend/src/app/fiat/fiat.component.html +++ b/frontend/src/app/fiat/fiat.component.html @@ -8,7 +8,10 @@ - + {{ (conversions[currency] > -1 ? conversions[currency] : 0) * value / 100000000 | fiatCurrency : digitsInfo : currency }} + +   + \ No newline at end of file diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 82e1ae50d..ad97d5f3d 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -39,6 +39,7 @@ export interface RbfTree extends RbfInfo { mined?: boolean; fullRbf: boolean; replaces: RbfTree[]; + replacedBy?: RbfTransaction; } export interface DifficultyAdjustment { @@ -172,13 +173,15 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; - status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'added' | 'censored' | 'selected' | 'fullrbf'; + rate?: number; // effective fee rate + status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf'; context?: 'projected' | 'actual'; } -interface RbfTransaction extends TransactionStripped { +export interface RbfTransaction extends TransactionStripped { rbf?: boolean; mined?: boolean, + fullRbf?: boolean, } export interface MempoolPosition { block: number, diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 20a114c72..15d97fa8d 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -18,6 +18,7 @@ export interface WebsocketResponse { txReplaced?: ReplacedTransaction; rbfInfo?: RbfTree; rbfLatest?: RbfTree[]; + rbfLatestSummary?: ReplacementInfo[]; utxoSpent?: object; transactions?: TransactionStripped[]; loadingIndicators?: ILoadingIndicators; @@ -29,6 +30,7 @@ export interface WebsocketResponse { 'track-asset'?: string; 'track-mempool-block'?: number; 'track-rbf'?: string; + 'track-rbf-summary'?: boolean; 'watch-mempool'?: boolean; 'track-bisq-market'?: string; 'refresh-blocks'?: boolean; @@ -37,6 +39,16 @@ export interface WebsocketResponse { export interface ReplacedTransaction extends Transaction { txid: string; } + +export interface ReplacementInfo { + mined: boolean; + fullRbf: boolean; + txid: string; + oldFee: number; + oldVsize: number; + newFee: number; + newVsize: number; +} export interface MempoolBlock { blink?: boolean; height?: number; @@ -77,7 +89,7 @@ export interface TransactionStripped { vsize: number; value: number; rate?: number; // effective fee rate - status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'added' | 'censored' | 'selected' | 'fullrbf'; + status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf'; context?: 'projected' | 'actual'; } diff --git a/frontend/src/app/lightning/channel/channel-preview.component.html b/frontend/src/app/lightning/channel/channel-preview.component.html index fe7f45a13..e59361e42 100644 --- a/frontend/src/app/lightning/channel/channel-preview.component.html +++ b/frontend/src/app/lightning/channel/channel-preview.component.html @@ -15,7 +15,7 @@
HeightMinedPoolTXsSizeTXIDPrevious feeNew feeStatus
{{ block.height }} - - - {{ block.extras.pool.name }} +
+ + {{ block.tx_count | number }} -
-
 
-
-
+
+ Mined + Full RBF + RBF
diff --git a/frontend/src/app/lightning/channel/channel-preview.component.scss b/frontend/src/app/lightning/channel/channel-preview.component.scss index 23a874ee8..6b6ac5152 100644 --- a/frontend/src/app/lightning/channel/channel-preview.component.scss +++ b/frontend/src/app/lightning/channel/channel-preview.component.scss @@ -1,3 +1,8 @@ +.table-col { + max-width: calc(100% - 470px); + overflow: hidden; +} + .table { font-size: 32px; margin-top: 10px; diff --git a/frontend/src/app/lightning/node/node-preview.component.scss b/frontend/src/app/lightning/node/node-preview.component.scss index baa33915b..da8794010 100644 --- a/frontend/src/app/lightning/node/node-preview.component.scss +++ b/frontend/src/app/lightning/node/node-preview.component.scss @@ -1,3 +1,8 @@ +.table-col { + max-width: calc(100% - 470px); + overflow: hidden; +} + .table { margin-top: 6px; font-size: 32px; @@ -18,10 +23,6 @@ } } -.table-col { - max-width: calc(100% - 470px); -} - .map-col { flex-grow: 0; flex-shrink: 0; diff --git a/frontend/src/app/lightning/node/node.component.html b/frontend/src/app/lightning/node/node.component.html index 2a74a68aa..c6c693a3a 100644 --- a/frontend/src/app/lightning/node/node.component.html +++ b/frontend/src/app/lightning/node/node.component.html @@ -21,7 +21,6 @@
-
@@ -59,6 +58,9 @@ + + +
Avg channel distance {{ avgDistance | amountShortener: 1 }} km ยท{{ kmToMiles(avgDistance) | amountShortener: 1 }} mi
@@ -100,11 +102,50 @@
+
+ + Features + + {{ bits }} + + + + +
+
+
+
+
Raw bits
+ {{ node.featuresBits }} +
+
Decoded
+ + + + + + + + + + + + + +
BitNameRequired
{{ feature.bit }}{{ feature.name }}{{ feature.is_required }}
+
+
diff --git a/frontend/src/app/lightning/node/node.component.ts b/frontend/src/app/lightning/node/node.component.ts index 47f65007f..719136d79 100644 --- a/frontend/src/app/lightning/node/node.component.ts +++ b/frontend/src/app/lightning/node/node.component.ts @@ -37,7 +37,7 @@ export class NodeComponent implements OnInit { liquidityAd: ILiquidityAd; tlvRecords: CustomRecord[]; avgChannelDistance$: Observable; - + showFeatures = false; kmToMiles = kmToMiles; constructor( @@ -164,4 +164,9 @@ export class NodeComponent implements OnInit { onLoadingEvent(e) { this.channelListLoading = e; } + + toggleFeatures() { + this.showFeatures = !this.showFeatures; + return false; + } } diff --git a/frontend/src/app/lightning/nodes-ranking/top-nodes-per-channels/top-nodes-per-channels.component.html b/frontend/src/app/lightning/nodes-ranking/top-nodes-per-channels/top-nodes-per-channels.component.html index 652a70cc3..94a887bb3 100644 --- a/frontend/src/app/lightning/nodes-ranking/top-nodes-per-channels/top-nodes-per-channels.component.html +++ b/frontend/src/app/lightning/nodes-ranking/top-nodes-per-channels/top-nodes-per-channels.component.html @@ -1,7 +1,7 @@
-

Liquidity Ranking

+

Connectivity Ranking

diff --git a/frontend/src/app/lightning/nodes-ranking/top-nodes-per-channels/top-nodes-per-channels.component.ts b/frontend/src/app/lightning/nodes-ranking/top-nodes-per-channels/top-nodes-per-channels.component.ts index 4db896cb7..607ec2a99 100644 --- a/frontend/src/app/lightning/nodes-ranking/top-nodes-per-channels/top-nodes-per-channels.component.ts +++ b/frontend/src/app/lightning/nodes-ranking/top-nodes-per-channels/top-nodes-per-channels.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; import { map, Observable } from 'rxjs'; import { INodesRanking, ITopNodesPerChannels } from '../../../interfaces/node-api.interface'; +import { SeoService } from '../../../services/seo.service'; import { StateService } from '../../../services/state.service'; import { GeolocationData } from '../../../shared/components/geolocation/geolocation.component'; import { LightningApiService } from '../../lightning-api.service'; @@ -22,6 +23,7 @@ export class TopNodesPerChannels implements OnInit { constructor( private apiService: LightningApiService, private stateService: StateService, + private seoService: SeoService, ) {} ngOnInit(): void { @@ -32,6 +34,8 @@ export class TopNodesPerChannels implements OnInit { } if (this.widget === false) { + this.seoService.setTitle($localize`:@@c50bf442cf99f6fc5f8b687c460f33234b879869:Connectivity Ranking`); + this.topNodesPerChannels$ = this.apiService.getTopNodesByChannels$().pipe( map((ranking) => { for (const i in ranking) { diff --git a/frontend/src/app/services/cache.service.ts b/frontend/src/app/services/cache.service.ts index 5eefd6e0a..8c90dc210 100644 --- a/frontend/src/app/services/cache.service.ts +++ b/frontend/src/app/services/cache.service.ts @@ -18,6 +18,7 @@ export class CacheService { txCache: { [txid: string]: Transaction } = {}; network: string; + blockHashCache: { [hash: string]: BlockExtended } = {}; blockCache: { [height: number]: BlockExtended } = {}; blockLoading: { [height: number]: boolean } = {}; copiesInBlockQueue: { [height: number]: number } = {}; @@ -27,8 +28,10 @@ export class CacheService { private stateService: StateService, private apiService: ApiService, ) { - this.stateService.blocks$.subscribe(([block]) => { - this.addBlockToCache(block); + this.stateService.blocks$.subscribe((blocks) => { + for (const block of blocks) { + this.addBlockToCache(block); + } this.clearBlocks(); }); this.stateService.chainTip$.subscribe((height) => { @@ -56,8 +59,11 @@ export class CacheService { } addBlockToCache(block: BlockExtended) { - this.blockCache[block.height] = block; - this.bumpBlockPriority(block.height); + if (!this.blockHashCache[block.id]) { + this.blockHashCache[block.id] = block; + this.blockCache[block.height] = block; + this.bumpBlockPriority(block.height); + } } async loadBlock(height) { @@ -105,7 +111,9 @@ export class CacheService { } else if ((this.tip - height) < KEEP_RECENT_BLOCKS) { this.bumpBlockPriority(height); } else { + const block = this.blockCache[height]; delete this.blockCache[height]; + delete this.blockHashCache[block.id]; delete this.copiesInBlockQueue[height]; } } @@ -113,6 +121,7 @@ export class CacheService { // remove all blocks from the cache resetBlockCache() { + this.blockHashCache = {}; this.blockCache = {}; this.blockLoading = {}; this.copiesInBlockQueue = {}; diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index 5756f7fb2..c87018741 100644 --- a/frontend/src/app/services/electrs-api.service.ts +++ b/frontend/src/app/services/electrs-api.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Transaction, Address, Outspend, Recent, Asset } from '../interfaces/electrs.interface'; import { StateService } from './state.service'; @@ -65,12 +65,12 @@ export class ElectrsApiService { return this.httpClient.get
(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address); } - getAddressTransactions$(address: string): Observable { - return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs'); - } - - getAddressTransactionsFromHash$(address: string, txid: string): Observable { - return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs/chain/' + txid); + getAddressTransactions$(address: string, txid?: string): Observable { + let params = new HttpParams(); + if (txid) { + params = params.append('after_txid', txid); + } + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); } getAsset$(assetId: string): Observable { diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 0c5f5a5d9..2258d8440 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,11 +1,11 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable, merge } from 'rxjs'; import { Transaction } from '../interfaces/electrs.interface'; -import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; +import { IBackendInfo, MempoolBlock, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, TransactionStripped } from '../interfaces/websocket.interface'; import { BlockExtended, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; -import { map, scan, shareReplay, tap } from 'rxjs/operators'; +import { filter, map, scan, shareReplay } from 'rxjs/operators'; import { StorageService } from './storage.service'; export interface MarkBlockState { @@ -90,10 +90,12 @@ export class StateService { blockVSize: number; env: Env; latestBlockHeight = -1; + blocks: BlockExtended[] = []; networkChanged$ = new ReplaySubject(1); lightningChanged$ = new ReplaySubject(1); - blocks$: ReplaySubject<[BlockExtended, string]>; + blocksSubject$ = new BehaviorSubject([]); + blocks$: Observable; transactions$ = new ReplaySubject(6); conversions$ = new ReplaySubject(1); bsqPrice$ = new ReplaySubject(1); @@ -102,9 +104,11 @@ export class StateService { mempoolBlockTransactions$ = new Subject(); mempoolBlockDelta$ = new Subject(); liveMempoolBlockTransactions$: Observable<{ [txid: string]: TransactionStripped}>; + txConfirmed$ = new Subject<[string, BlockExtended]>(); txReplaced$ = new Subject(); txRbfInfo$ = new Subject(); rbfLatest$ = new Subject(); + rbfLatestSummary$ = new Subject(); utxoSpent$ = new Subject(); difficultyAdjustment$ = new ReplaySubject(1); mempoolTransactions$ = new Subject(); @@ -126,6 +130,7 @@ export class StateService { markBlock$ = new BehaviorSubject({}); keyNavigation$ = new Subject(); + searchText$ = new BehaviorSubject(''); blockScrolling$: Subject = new Subject(); resetScroll$: Subject = new Subject(); @@ -167,8 +172,6 @@ export class StateService { } }); - this.blocks$ = new ReplaySubject<[BlockExtended, string]>(this.env.KEEP_BLOCKS_AMOUNT); - this.liveMempoolBlockTransactions$ = merge( this.mempoolBlockTransactions$.pipe(map(transactions => { return { transactions }; })), this.mempoolBlockDelta$.pipe(map(delta => { return { delta }; })), @@ -198,8 +201,15 @@ export class StateService { this.networkChanged$.next(this.env.BASE_MODULE); } + this.networkChanged$.subscribe((network) => { + this.transactions$ = new ReplaySubject(6); + this.blocksSubject$.next([]); + }); + this.blockVSize = this.env.BLOCK_WEIGHT_UNITS / 4; + this.blocks$ = this.blocksSubject$.pipe(filter(blocks => blocks != null && blocks.length > 0)); + const savedTimePreference = this.storageService.getValue('time-preference-ltr'); const rtlLanguage = (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')); // default time direction is right-to-left, unless locale is a RTL language @@ -336,4 +346,15 @@ export class StateService { this.chainTip$.next(height); } } + + resetBlocks(blocks: BlockExtended[]): void { + this.blocks = blocks.reverse(); + this.blocksSubject$.next(blocks); + } + + addBlock(block: BlockExtended): void { + this.blocks.unshift(block); + this.blocks = this.blocks.slice(0, this.env.KEEP_BLOCKS_AMOUNT); + this.blocksSubject$.next(this.blocks); + } } diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index d22717b2a..f32f772ac 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -1,13 +1,13 @@ import { Injectable } from '@angular/core'; import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; -import { WebsocketResponse, IBackendInfo } from '../interfaces/websocket.interface'; +import { WebsocketResponse } from '../interfaces/websocket.interface'; import { StateService } from './state.service'; import { Transaction } from '../interfaces/electrs.interface'; import { Subscription } from 'rxjs'; import { ApiService } from './api.service'; import { take } from 'rxjs/operators'; import { TransferState, makeStateKey } from '@angular/platform-browser'; -import { BlockExtended } from '../interfaces/node-api.interface'; +import { CacheService } from './cache.service'; const OFFLINE_RETRY_AFTER_MS = 2000; const OFFLINE_PING_CHECK_AFTER_MS = 30000; @@ -29,6 +29,7 @@ export class WebsocketService { private trackingTxId: string; private isTrackingMempoolBlock = false; private isTrackingRbf = false; + private isTrackingRbfSummary = false; private trackingMempoolBlock: number; private latestGitCommit = ''; private onlineCheckTimeout: number; @@ -40,6 +41,7 @@ export class WebsocketService { private stateService: StateService, private apiService: ApiService, private transferState: TransferState, + private cacheService: CacheService, ) { if (!this.stateService.isBrowser) { // @ts-ignore @@ -184,6 +186,16 @@ export class WebsocketService { this.isTrackingRbf = false; } + startTrackRbfSummary() { + this.websocketSubject.next({ 'track-rbf-summary': true }); + this.isTrackingRbfSummary = true; + } + + stopTrackRbfSummary() { + this.websocketSubject.next({ 'track-rbf-summary': false }); + this.isTrackingRbfSummary = false; + } + startTrackBisqMarket(market: string) { this.websocketSubject.next({ 'track-bisq-market': market }); } @@ -239,13 +251,8 @@ export class WebsocketService { if (response.blocks && response.blocks.length) { const blocks = response.blocks; - let maxHeight = 0; - blocks.forEach((block: BlockExtended) => { - if (block.height > this.stateService.latestBlockHeight) { - maxHeight = Math.max(maxHeight, block.height); - this.stateService.blocks$.next([block, '']); - } - }); + this.stateService.resetBlocks(blocks); + const maxHeight = blocks.reduce((max, block) => Math.max(max, block.height), this.stateService.latestBlockHeight); this.stateService.updateChainTip(maxHeight); } @@ -260,7 +267,8 @@ export class WebsocketService { if (response.block) { if (response.block.height === this.stateService.latestBlockHeight + 1) { this.stateService.updateChainTip(response.block.height); - this.stateService.blocks$.next([response.block, response.txConfirmed || '']); + this.stateService.addBlock(response.block); + this.stateService.txConfirmed$.next([response.txConfirmed, response.block]); } else if (response.block.height > this.stateService.latestBlockHeight + 1) { reinitBlocks = true; } @@ -286,6 +294,10 @@ export class WebsocketService { this.stateService.rbfLatest$.next(response.rbfLatest); } + if (response.rbfLatestSummary) { + this.stateService.rbfLatestSummary$.next(response.rbfLatestSummary); + } + if (response.txReplaced) { this.stateService.txReplaced$.next(response.txReplaced); } diff --git a/frontend/src/app/shared/components/confirmations/confirmations.component.html b/frontend/src/app/shared/components/confirmations/confirmations.component.html index e785ce4dd..db3f1f38a 100644 --- a/frontend/src/app/shared/components/confirmations/confirmations.component.html +++ b/frontend/src/app/shared/components/confirmations/confirmations.component.html @@ -5,12 +5,15 @@ {{ i }} confirmations + + + - + \ No newline at end of file diff --git a/frontend/src/app/shared/pipes/bitcoinsatoshis.pipe.ts b/frontend/src/app/shared/pipes/bitcoinsatoshis.pipe.ts new file mode 100644 index 000000000..7065b5138 --- /dev/null +++ b/frontend/src/app/shared/pipes/bitcoinsatoshis.pipe.ts @@ -0,0 +1,28 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; + +@Pipe({ + name: 'bitcoinsatoshis' +}) +export class BitcoinsatoshisPipe implements PipeTransform { + + constructor(private sanitizer: DomSanitizer) { } + + transform(value: string): SafeHtml { + const newValue = this.insertSpaces(parseFloat(value || '0').toFixed(8)); + const position = (newValue || '0').search(/[1-9]/); + + const firstPart = newValue.slice(0, position); + const secondPart = newValue.slice(position); + + return this.sanitizer.bypassSecurityTrustHtml( + `${firstPart}${secondPart}` + ); + } + + insertSpaces(str: string): string { + const length = str.length; + return str.slice(0, length - 6) + ' ' + str.slice(length - 6, length - 3) + ' ' + str.slice(length - 3); + } + +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 9cf780116..d56986107 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -97,6 +97,8 @@ import { MempoolBlockOverviewComponent } from '../components/mempool-block-overv import { ClockchainComponent } from '../components/clockchain/clockchain.component'; import { ClockFaceComponent } from '../components/clock-face/clock-face.component'; import { ClockComponent } from '../components/clock/clock.component'; +import { CalculatorComponent } from '../components/calculator/calculator.component'; +import { BitcoinsatoshisPipe } from '../shared/pipes/bitcoinsatoshis.pipe'; import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-directives/weight-directives'; @@ -185,12 +187,12 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir GeolocationComponent, TestnetAlertComponent, GlobalFooterComponent, - + CalculatorComponent, + BitcoinsatoshisPipe, MempoolBlockOverviewComponent, ClockchainComponent, ClockComponent, ClockFaceComponent, - OnlyVsizeDirective, OnlyWeightDirective ], diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index e5eb2272b..428752d60 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -993,6 +993,10 @@ th { margin-right: 10px; } } + + .btn-audit { + margin-left: .5em; + } } .scriptmessage { @@ -1160,3 +1164,10 @@ app-master-page, app-liquid-master-page, app-bisq-master-page { app-global-footer { margin-top: auto; } + +.btn-xs { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + line-height: 0.5; + border-radius: 0.2rem; +} diff --git a/production/bitcoin.conf b/production/bitcoin.conf index 501f49f50..fb8ad52e5 100644 --- a/production/bitcoin.conf +++ b/production/bitcoin.conf @@ -8,7 +8,8 @@ par=16 dbcache=8192 maxmempool=4096 mempoolexpiry=999999 -maxconnections=42 +mempoolfullrbf=1 +maxconnections=100 onion=127.0.0.1:9050 rpcallowip=127.0.0.1 rpcuser=__BITCOIN_RPC_USER__ diff --git a/production/bitcoin.minfee.conf b/production/bitcoin.minfee.conf index d8ff97258..0bd7f2ed1 100644 --- a/production/bitcoin.minfee.conf +++ b/production/bitcoin.minfee.conf @@ -4,6 +4,7 @@ txindex=0 listen=1 daemon=1 prune=1337 +mempoolfullrbf=1 rpcallowip=127.0.0.1 rpcuser=__BITCOIN_RPC_USER__ rpcpassword=__BITCOIN_RPC_PASS__ diff --git a/production/install b/production/install index 0297d4b4f..2a5064b89 100755 --- a/production/install +++ b/production/install @@ -353,7 +353,7 @@ ELEMENTS_REPO_URL=https://github.com/ElementsProject/elements ELEMENTS_REPO_NAME=elements ELEMENTS_REPO_BRANCH=master #ELEMENTS_LATEST_RELEASE=$(curl -s https://api.github.com/repos/ElementsProject/elements/releases/latest|grep tag_name|head -1|cut -d '"' -f4) -ELEMENTS_LATEST_RELEASE=elements-22.1 +ELEMENTS_LATEST_RELEASE=elements-22.1.1 echo -n '.' BITCOIN_ELECTRS_REPO_URL=https://github.com/mempool/electrs @@ -1045,7 +1045,7 @@ echo "[*] Installing nvm.sh from GitHub" osSudo "${MEMPOOL_USER}" sh -c 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | zsh' echo "[*] Building NodeJS via nvm.sh" -osSudo "${MEMPOOL_USER}" zsh -c 'source ~/.zshrc ; nvm install v16.16.0 --shared-zlib' +osSudo "${MEMPOOL_USER}" zsh -c 'source ~/.zshrc ; nvm install v20.4.0 --shared-zlib' #################### # Tor installation # @@ -1240,8 +1240,8 @@ if [ "${BITCOIN_ELECTRS_INSTALL}" = ON ];then FreeBSD) echo "[*] Patching Bitcoin Electrs code for FreeBSD" osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_HOME}/.cargo/registry/src/index.crates.io-6f17d22bba15001f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\"" - osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/new_index/\" && sed -i.bak -e s/Snappy/None/ db.rs && rm db.rs.bak" - osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/bin/\" && sed -i.bak -e 's/from_secs(5)/from_secs(1)/' electrs.rs && rm electrs.rs.bak" + #osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/new_index/\" && sed -i.bak -e s/Snappy/None/ db.rs && rm db.rs.bak" + #osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/bin/\" && sed -i.bak -e 's/from_secs(5)/from_secs(1)/' electrs.rs && rm electrs.rs.bak" ;; Debian) ;; diff --git a/production/mempool-config.mainnet-lightning.json b/production/mempool-config.mainnet-lightning.json index 21e7109e9..41e42a5bd 100644 --- a/production/mempool-config.mainnet-lightning.json +++ b/production/mempool-config.mainnet-lightning.json @@ -1,5 +1,6 @@ { "MEMPOOL": { + "ENABLED": false, "NETWORK": "mainnet", "BACKEND": "esplora", "HTTP_PORT": 8993, diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json index 8630f1fcd..5e25bcb76 100644 --- a/production/mempool-config.mainnet.json +++ b/production/mempool-config.mainnet.json @@ -16,7 +16,9 @@ "ADVANCED_GBT_MEMPOOL": true, "RUST_GBT": true, "USE_SECOND_NODE_FOR_MINFEE": true, - "DISK_CACHE_BLOCK_INTERVAL": 1 + "DISK_CACHE_BLOCK_INTERVAL": 1, + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "SYSLOG" : { "MIN_PRIORITY": "debug" @@ -46,5 +48,30 @@ "STATISTICS": { "ENABLED": true, "TX_PER_SECOND_SAMPLE_PERIOD": 150 + }, + "REPLICATION": { + "ENABLED": true, + "AUDIT": true, + "AUDIT_START_HEIGHT": 774000, + "SERVERS": [ + "node201.fmt.mempool.space", + "node202.fmt.mempool.space", + "node203.fmt.mempool.space", + "node204.fmt.mempool.space", + "node205.fmt.mempool.space", + "node206.fmt.mempool.space", + "node201.fra.mempool.space", + "node202.fra.mempool.space", + "node203.fra.mempool.space", + "node204.fra.mempool.space", + "node205.fra.mempool.space", + "node206.fra.mempool.space", + "node201.tk7.mempool.space", + "node202.tk7.mempool.space", + "node203.tk7.mempool.space", + "node204.tk7.mempool.space", + "node205.tk7.mempool.space", + "node206.tk7.mempool.space" + ] } } diff --git a/production/mempool-config.signet-lightning.json b/production/mempool-config.signet-lightning.json index 7751d8f0e..9971729e2 100644 --- a/production/mempool-config.signet-lightning.json +++ b/production/mempool-config.signet-lightning.json @@ -1,5 +1,6 @@ { "MEMPOOL": { + "ENABLED": false, "NETWORK": "signet", "BACKEND": "esplora", "HTTP_PORT": 8991, diff --git a/production/mempool-config.signet.json b/production/mempool-config.signet.json index e216ed216..957b36101 100644 --- a/production/mempool-config.signet.json +++ b/production/mempool-config.signet.json @@ -12,7 +12,9 @@ "ADVANCED_GBT_MEMPOOL": true, "RUST_GBT": true, "POLL_RATE_MS": 1000, - "DISK_CACHE_BLOCK_INTERVAL": 1 + "DISK_CACHE_BLOCK_INTERVAL": 1, + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "SYSLOG" : { "MIN_PRIORITY": "debug" diff --git a/production/mempool-config.testnet-lightning.json b/production/mempool-config.testnet-lightning.json index d8283b779..ff7d4766f 100644 --- a/production/mempool-config.testnet-lightning.json +++ b/production/mempool-config.testnet-lightning.json @@ -1,5 +1,6 @@ { "MEMPOOL": { + "ENABLED": false, "NETWORK": "testnet", "BACKEND": "esplora", "HTTP_PORT": 8992, diff --git a/production/mempool-config.testnet.json b/production/mempool-config.testnet.json index 02bf892c1..8943e987f 100644 --- a/production/mempool-config.testnet.json +++ b/production/mempool-config.testnet.json @@ -12,7 +12,9 @@ "ADVANCED_GBT_MEMPOOL": true, "RUST_GBT": true, "POLL_RATE_MS": 1000, - "DISK_CACHE_BLOCK_INTERVAL": 1 + "DISK_CACHE_BLOCK_INTERVAL": 1, + "MAX_PUSH_TX_SIZE_WEIGHT": 4000000, + "ALLOW_UNREACHABLE": true }, "SYSLOG" : { "MIN_PRIORITY": "debug" diff --git a/unfurler/package-lock.json b/unfurler/package-lock.json index 40520d413..16968f203 100644 --- a/unfurler/package-lock.json +++ b/unfurler/package-lock.json @@ -1,12 +1,12 @@ { "name": "mempool-unfurl", - "version": "0.1.0", + "version": "3.0.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mempool-unfurl", - "version": "0.1.0", + "version": "3.0.0-dev", "dependencies": { "@types/node": "^16.11.41", "express": "^4.18.0", diff --git a/unfurler/package.json b/unfurler/package.json index 59d48aa50..ec0a153b6 100644 --- a/unfurler/package.json +++ b/unfurler/package.json @@ -1,6 +1,6 @@ { "name": "mempool-unfurl", - "version": "0.1.0", + "version": "3.0.0-dev", "description": "Renderer for mempool open graph link preview images", "repository": { "type": "git", diff --git a/unfurler/src/index.ts b/unfurler/src/index.ts index 0b423ff92..fedf32110 100644 --- a/unfurler/src/index.ts +++ b/unfurler/src/index.ts @@ -109,7 +109,10 @@ class Server { page.waitForSelector('meta[property="og:preview:fail"]', { timeout: config.PUPPETEER.RENDER_TIMEOUT || 3000 }).then(() => false) ]) if (success === true) { - const screenshot = await page.screenshot(); + const screenshot = await page.screenshot({ + captureBeyondViewport: false, + clip: { width: 1200, height: 600, x: 0, y: 0, scale: 1 }, + }); return screenshot; } else if (success === false) { logger.warn(`failed to render ${path} for ${action} due to client-side error, e.g. requested an invalid txid`);