diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index b6f8ab657..751bab5a3 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -1,9 +1,12 @@ 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 { Ancestor, CpfpInfo, CpfpSummary, CpfpCluster, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats, TransactionClassified, TransactionFlags } from '../mempool.interfaces'; import config from '../config'; import { NodeSocket } from '../repositories/NodesSocketsRepository'; import { isIP } from 'net'; +import rbfCache from './rbf-cache'; +import transactionUtils from './transaction-utils'; +import { isPoint } from '../utils/secp256k1'; export class Common { static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ? '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49' @@ -138,6 +141,206 @@ export class Common { return matches; } + static setSchnorrSighashFlags(flags: bigint, witness: string[]): bigint { + // no witness items + if (!witness?.length) { + return flags; + } + const hasAnnex = witness.length > 1 && witness[witness.length - 1].startsWith('50'); + if (witness?.length === (hasAnnex ? 2 : 1)) { + // keypath spend, signature is the only witness item + if (witness[0].length === 130) { + flags |= this.setSighashFlags(flags, witness[0]); + } else { + flags |= TransactionFlags.sighash_default; + } + } else { + // scriptpath spend, all items except for the script, control block and annex could be signatures + for (let i = 0; i < witness.length - (hasAnnex ? 3 : 2); i++) { + // handle probable signatures + if (witness[i].length === 130) { + flags |= this.setSighashFlags(flags, witness[i]); + } else if (witness[i].length === 128) { + flags |= TransactionFlags.sighash_default; + } + } + } + return flags; + } + + static isDERSig(w: string): boolean { + // heuristic to detect probable DER signatures + return (w.length >= 18 + && w.startsWith('30') // minimum DER signature length is 8 bytes + sighash flag (see https://mempool.space/testnet/tx/c6c232a36395fa338da458b86ff1327395a9afc28c5d2daa4273e410089fd433) + && ['01', '02', '03', '81', '82', '83'].includes(w.slice(-2)) // signature must end with a valid sighash flag + && (w.length === (2 * parseInt(w.slice(2, 4), 16)) + 6) // second byte encodes the combined length of the R and S components + ); + } + + static setSegwitSighashFlags(flags: bigint, witness: string[]): bigint { + for (const w of witness) { + if (this.isDERSig(w)) { + flags |= this.setSighashFlags(flags, w); + } + } + return flags; + } + + static setLegacySighashFlags(flags: bigint, scriptsig_asm: string): bigint { + for (const item of scriptsig_asm.split(' ')) { + // skip op_codes + if (item.startsWith('OP_')) { + continue; + } + // check pushed data + if (this.isDERSig(item)) { + flags |= this.setSighashFlags(flags, item); + } + } + return flags; + } + + static setSighashFlags(flags: bigint, signature: string): bigint { + switch(signature.slice(-2)) { + case '01': return flags | TransactionFlags.sighash_all; + case '02': return flags | TransactionFlags.sighash_none; + case '03': return flags | TransactionFlags.sighash_single; + case '81': return flags | TransactionFlags.sighash_all | TransactionFlags.sighash_acp; + case '82': return flags | TransactionFlags.sighash_none | TransactionFlags.sighash_acp; + case '83': return flags | TransactionFlags.sighash_single | TransactionFlags.sighash_acp; + default: return flags | TransactionFlags.sighash_default; // taproot only + } + } + + static isBurnKey(pubkey: string): boolean { + return [ + '022222222222222222222222222222222222222222222222222222222222222222', + '033333333333333333333333333333333333333333333333333333333333333333', + '020202020202020202020202020202020202020202020202020202020202020202', + '030303030303030303030303030303030303030303030303030303030303030303', + ].includes(pubkey); + } + + static getTransactionFlags(tx: TransactionExtended): number { + let flags = 0n; + if (tx.version === 1) { + flags |= TransactionFlags.v1; + } else if (tx.version === 2) { + flags |= TransactionFlags.v2; + } + const reusedAddresses: { [address: string ]: number } = {}; + const inValues = {}; + const outValues = {}; + let rbf = false; + for (const vin of tx.vin) { + if (vin.sequence < 0xfffffffe) { + rbf = true; + } + switch (vin.prevout?.scriptpubkey_type) { + case 'p2pk': flags |= TransactionFlags.p2pk; break; + case 'multisig': flags |= TransactionFlags.p2ms; break; + case 'p2pkh': flags |= TransactionFlags.p2pkh; break; + case 'p2sh': flags |= TransactionFlags.p2sh; break; + case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break; + case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break; + case 'v1_p2tr': { + flags |= TransactionFlags.p2tr; + if (vin.witness.length > 2) { + const asm = vin.inner_witnessscript_asm || transactionUtils.convertScriptSigAsm(vin.witness[vin.witness.length - 2]); + if (asm?.includes('OP_0 OP_IF')) { + flags |= TransactionFlags.inscription; + } + } + } break; + } + + // sighash flags + if (vin.prevout?.scriptpubkey_type === 'v1_p2tr') { + flags |= this.setSchnorrSighashFlags(flags, vin.witness); + } else if (vin.witness) { + flags |= this.setSegwitSighashFlags(flags, vin.witness); + } else if (vin.scriptsig?.length) { + flags |= this.setLegacySighashFlags(flags, vin.scriptsig_asm || transactionUtils.convertScriptSigAsm(vin.scriptsig)); + } + + if (vin.prevout?.scriptpubkey_address) { + reusedAddresses[vin.prevout?.scriptpubkey_address] = (reusedAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1; + } + inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1; + } + if (rbf) { + flags |= TransactionFlags.rbf; + } else { + flags |= TransactionFlags.no_rbf; + } + let hasFakePubkey = false; + for (const vout of tx.vout) { + switch (vout.scriptpubkey_type) { + case 'p2pk': { + flags |= TransactionFlags.p2pk; + // detect fake pubkey (i.e. not a valid DER point on the secp256k1 curve) + hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey.slice(2, -2)); + } break; + case 'multisig': { + flags |= TransactionFlags.p2ms; + // detect fake pubkeys (i.e. not valid DER points on the secp256k1 curve) + const asm = vout.scriptpubkey_asm || transactionUtils.convertScriptSigAsm(vout.scriptpubkey); + for (const key of (asm?.split(' ') || [])) { + if (!hasFakePubkey && !key.startsWith('OP_')) { + hasFakePubkey = hasFakePubkey || this.isBurnKey(key) || !isPoint(key); + } + } + } break; + case 'p2pkh': flags |= TransactionFlags.p2pkh; break; + case 'p2sh': flags |= TransactionFlags.p2sh; break; + case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break; + case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break; + case 'v1_p2tr': flags |= TransactionFlags.p2tr; break; + case 'op_return': flags |= TransactionFlags.op_return; break; + } + if (vout.scriptpubkey_address) { + reusedAddresses[vout.scriptpubkey_address] = (reusedAddresses[vout.scriptpubkey_address] || 0) + 1; + } + outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1; + } + if (hasFakePubkey) { + flags |= TransactionFlags.fake_pubkey; + } + if (tx.ancestors?.length) { + flags |= TransactionFlags.cpfp_child; + } + if (tx.descendants?.length) { + flags |= TransactionFlags.cpfp_parent; + } + if (rbfCache.getRbfTree(tx.txid)) { + flags |= TransactionFlags.replacement; + } + // fast but bad heuristic to detect possible coinjoins + // (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse) + const addressReuse = Object.values(reusedAddresses).reduce((acc, count) => Math.max(acc, count), 0) > 1; + if (!addressReuse && tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) { + flags |= TransactionFlags.coinjoin; + } + // more than 5:1 input:output ratio + if (tx.vin.length / tx.vout.length >= 5) { + flags |= TransactionFlags.consolidation; + } + // less than 1:5 input:output ratio + if (tx.vin.length / tx.vout.length <= 0.2) { + flags |= TransactionFlags.batch_payout; + } + + return Number(flags); + } + + static classifyTransaction(tx: TransactionExtended): TransactionClassified { + const flags = this.getTransactionFlags(tx); + return { + ...this.stripTransaction(tx), + flags, + }; + } + static stripTransaction(tx: TransactionExtended): TransactionStripped { return { txid: tx.txid, diff --git a/backend/src/api/fee-api.ts b/backend/src/api/fee-api.ts index 5260e959a..0cab5a295 100644 --- a/backend/src/api/fee-api.ts +++ b/backend/src/api/fee-api.ts @@ -1,8 +1,10 @@ import { MempoolBlock } from '../mempool.interfaces'; -import { Common } from './common'; +import config from '../config'; import mempool from './mempool'; import projectedBlocks from './mempool-blocks'; +const isLiquid = config.MEMPOOL.NETWORK === 'liquid' || config.MEMPOOL.NETWORK === 'liquidtestnet'; + interface RecommendedFees { fastestFee: number, halfHourFee: number, @@ -14,8 +16,8 @@ interface RecommendedFees { class FeeApi { constructor() { } - defaultFee = Common.isLiquid() ? 0.1 : 1; - minimumIncrement = Common.isLiquid() ? 0.1 : 1; + defaultFee = isLiquid ? 0.1 : 1; + minimumIncrement = isLiquid ? 0.1 : 1; public getRecommendedFee(): RecommendedFees { const pBlocks = projectedBlocks.getMempoolBlocks(); diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index 15f9b6cf7..a7f00f6e8 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -1,6 +1,6 @@ import { GbtGenerator, GbtResult, ThreadTransaction as RustThreadTransaction, ThreadAcceleration as RustThreadAcceleration } from 'rust-gbt'; import logger from '../logger'; -import { MempoolBlock, MempoolTransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats, PoolTag } from '../mempool.interfaces'; +import { MempoolBlock, MempoolTransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats, PoolTag, TransactionClassified } from '../mempool.interfaces'; import { Common, OnlineFeeStatsCalculator } from './common'; import config from '../config'; import { Worker } from 'worker_threads'; @@ -169,7 +169,7 @@ class MempoolBlocks { private calculateMempoolDeltas(prevBlocks: MempoolBlockWithTransactions[], mempoolBlocks: MempoolBlockWithTransactions[]): MempoolBlockDelta[] { const mempoolBlockDeltas: MempoolBlockDelta[] = []; for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { - let added: TransactionStripped[] = []; + let added: TransactionClassified[] = []; let removed: string[] = []; const changed: { txid: string, rate: number | undefined, acc: boolean | undefined }[] = []; if (mempoolBlocks[i] && !prevBlocks[i]) { @@ -582,6 +582,7 @@ class MempoolBlocks { const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks); this.mempoolBlocks = mempoolBlocks; this.mempoolBlockDeltas = deltas; + } return mempoolBlocks; @@ -599,7 +600,7 @@ class MempoolBlocks { medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE), feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength), transactionIds: transactionIds, - transactions: transactions.map((tx) => Common.stripTransaction(tx)), + transactions: transactions.map((tx) => Common.classifyTransaction(tx)), }; } diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index fa13db418..a5bc8407a 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -100,6 +100,9 @@ class Mempool { if (this.mempoolCache[txid].order == null) { this.mempoolCache[txid].order = transactionUtils.txidToOrdering(txid); } + for (const vin of this.mempoolCache[txid].vin) { + transactionUtils.addInnerScriptsToVin(vin); + } count++; if (config.MEMPOOL.CACHE_ENABLED && config.REDIS.ENABLED) { await redisCache.$addTransaction(this.mempoolCache[txid]); diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index cb212512c..4a630f1e4 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -61,13 +61,13 @@ export interface MempoolBlock { export interface MempoolBlockWithTransactions extends MempoolBlock { transactionIds: string[]; - transactions: TransactionStripped[]; + transactions: TransactionClassified[]; } export interface MempoolBlockDelta { - added: TransactionStripped[]; + added: TransactionClassified[]; removed: string[]; - changed: { txid: string, rate: number | undefined }[]; + changed: { txid: string, rate: number | undefined, flags?: number }[]; } interface VinStrippedToScriptsig { @@ -190,6 +190,45 @@ export interface TransactionStripped { rate?: number; // effective fee rate } +export interface TransactionClassified extends TransactionStripped { + flags: number; +} + +// binary flags for transaction classification +export const TransactionFlags = { + // features + rbf: 0b00000001n, + no_rbf: 0b00000010n, + v1: 0b00000100n, + v2: 0b00001000n, + // address types + p2pk: 0b00000001_00000000n, + p2ms: 0b00000010_00000000n, + p2pkh: 0b00000100_00000000n, + p2sh: 0b00001000_00000000n, + p2wpkh: 0b00010000_00000000n, + p2wsh: 0b00100000_00000000n, + p2tr: 0b01000000_00000000n, + // behavior + cpfp_parent: 0b00000001_00000000_00000000n, + cpfp_child: 0b00000010_00000000_00000000n, + replacement: 0b00000100_00000000_00000000n, + // data + op_return: 0b00000001_00000000_00000000_00000000n, + fake_pubkey: 0b00000010_00000000_00000000_00000000n, + inscription: 0b00000100_00000000_00000000_00000000n, + // heuristics + coinjoin: 0b00000001_00000000_00000000_00000000_00000000n, + consolidation: 0b00000010_00000000_00000000_00000000_00000000n, + batch_payout: 0b00000100_00000000_00000000_00000000_00000000n, + // sighash + sighash_all: 0b00000001_00000000_00000000_00000000_00000000_00000000n, + sighash_none: 0b00000010_00000000_00000000_00000000_00000000_00000000n, + sighash_single: 0b00000100_00000000_00000000_00000000_00000000_00000000n, + sighash_default:0b00001000_00000000_00000000_00000000_00000000_00000000n, + sighash_acp: 0b00010000_00000000_00000000_00000000_00000000_00000000n, +}; + export interface BlockExtension { totalFees: number; medianFee: number; // median fee rate diff --git a/backend/src/utils/secp256k1.ts b/backend/src/utils/secp256k1.ts new file mode 100644 index 000000000..cc731f17d --- /dev/null +++ b/backend/src/utils/secp256k1.ts @@ -0,0 +1,74 @@ +function powMod(x: bigint, power: number, modulo: bigint): bigint { + for (let i = 0; i < power; i++) { + x = (x * x) % modulo; + } + return x; +} + +function sqrtMod(x: bigint, P: bigint): bigint { + const b2 = (x * x * x) % P; + const b3 = (b2 * b2 * x) % P; + const b6 = (powMod(b3, 3, P) * b3) % P; + const b9 = (powMod(b6, 3, P) * b3) % P; + const b11 = (powMod(b9, 2, P) * b2) % P; + const b22 = (powMod(b11, 11, P) * b11) % P; + const b44 = (powMod(b22, 22, P) * b22) % P; + const b88 = (powMod(b44, 44, P) * b44) % P; + const b176 = (powMod(b88, 88, P) * b88) % P; + const b220 = (powMod(b176, 44, P) * b44) % P; + const b223 = (powMod(b220, 3, P) * b3) % P; + const t1 = (powMod(b223, 23, P) * b22) % P; + const t2 = (powMod(t1, 6, P) * b2) % P; + const root = powMod(t2, 2, P); + return root; +} + +const curveP = BigInt(`0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F`); + +/** + * This function tells whether the point given is a DER encoded point on the ECDSA curve. + * @param {string} pointHex The point as a hex string (*must not* include a '0x' prefix) + * @returns {boolean} true if the point is on the SECP256K1 curve + */ +export function isPoint(pointHex: string): boolean { + if ( + !( + // is uncompressed + ( + (pointHex.length === 130 && pointHex.startsWith('04')) || + // OR is compressed + (pointHex.length === 66 && + (pointHex.startsWith('02') || pointHex.startsWith('03'))) + ) + ) + ) { + return false; + } + + // Function modified slightly from noble-curves + + + // Now we know that pointHex is a 33 or 65 byte hex string. + const isCompressed = pointHex.length === 66; + + const x = BigInt(`0x${pointHex.slice(2, 66)}`); + if (x >= curveP) { + return false; + } + + if (!isCompressed) { + const y = BigInt(`0x${pointHex.slice(66, 130)}`); + if (y >= curveP) { + return false; + } + // Just check y^2 = x^3 + 7 (secp256k1 curve) + return (y * y) % curveP === (x * x * x + 7n) % curveP; + } else { + // Get unaltered y^2 (no mod p) + const ySquared = (x * x * x + 7n) % curveP; + // Try to sqrt it, it will round down if not perfect root + const y = sqrtMod(ySquared, curveP); + // If we square and it's equal, then it was a perfect root and valid point. + return (y * y) % curveP === ySquared; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/block-filters/block-filters.component.html b/frontend/src/app/components/block-filters/block-filters.component.html new file mode 100644 index 000000000..90b66ddc3 --- /dev/null +++ b/frontend/src/app/components/block-filters/block-filters.component.html @@ -0,0 +1,29 @@ +
+
+ +
+ + + +
+
+
+ +
{{ group.label }}
+
+ + + +
+
+
+
+ + + + + +
+
\ No newline at end of file diff --git a/frontend/src/app/components/block-filters/block-filters.component.scss b/frontend/src/app/components/block-filters/block-filters.component.scss new file mode 100644 index 000000000..20b565293 --- /dev/null +++ b/frontend/src/app/components/block-filters/block-filters.component.scss @@ -0,0 +1,128 @@ +.block-filters { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + padding: 1em; + z-index: 10; + pointer-events: none; + + .filter-bar, .active-tags { + display: flex; + flex-direction: row; + align-items: center; + } + + .active-tags { + flex-wrap: wrap; + row-gap: 0.25em; + margin-left: 0.5em; + } + + .menu-toggle { + opacity: 0; + cursor: pointer; + color: white; + background: none; + border: solid 2px white; + border-radius: 0.35em; + pointer-events: all; + } + + .filter-menu { + h5 { + font-size: 0.8rem; + color: white; + margin: 0; + margin-top: 0.5em; + } + } + + .filter-group { + display: flex; + flex-direction: row; + flex-wrap: wrap; + row-gap: 0.25em; + margin-bottom: 0.5em; + } + + .filter-tag { + font-size: 0.9em; + background: #181b2daf; + border: solid 1px #105fb0; + color: white; + border-radius: 0.2rem; + padding: 0.2em 0.5em; + transition: background-color 300ms; + margin-right: 0.25em; + pointer-events: all; + + &.selected { + background-color: #105fb0; + } + } + + :host-context(.block-overview-graph:hover) &, &:hover, &:active { + .menu-toggle { + opacity: 0.5; + background: #181b2d; + + &:hover { + opacity: 1; + background: #181b2d7f; + } + } + + &.menu-open, &.filters-active { + .menu-toggle { + opacity: 1; + background: none; + + &:hover { + background: #181b2d7f; + } + } + } + } + + &.menu-open, &.filters-active { + .menu-toggle { + opacity: 1; + background: none; + + &:hover { + background: #181b2d7f; + } + } + } + + &.menu-open { + pointer-events: all; + background: #181b2d7f; + } + + &.small { + .filter-tag { + font-size: 0.8em; + } + } + + &.vsmall { + .filter-menu { + margin-top: 0.25em; + h5 { + display: none; + } + } + .filter-tag { + font-size: 0.7em; + } + } + + &.tiny { + .filter-tag { + font-size: 0.5em; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/block-filters/block-filters.component.ts b/frontend/src/app/components/block-filters/block-filters.component.ts new file mode 100644 index 000000000..ce0dd76ab --- /dev/null +++ b/frontend/src/app/components/block-filters/block-filters.component.ts @@ -0,0 +1,70 @@ +import { Component, EventEmitter, Output, HostListener, Input, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core'; +import { FilterGroups, TransactionFilters } from '../../shared/filters.utils'; + + +@Component({ + selector: 'app-block-filters', + templateUrl: './block-filters.component.html', + styleUrls: ['./block-filters.component.scss'], +}) +export class BlockFiltersComponent implements OnChanges { + @Input() cssWidth: number = 800; + @Output() onFilterChanged: EventEmitter = new EventEmitter(); + + filters = TransactionFilters; + filterGroups = FilterGroups; + activeFilters: string[] = []; + filterFlags: { [key: string]: boolean } = {}; + menuOpen: boolean = false; + + constructor( + private cd: ChangeDetectorRef, + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes.cssWidth) { + this.cd.markForCheck(); + } + } + + toggleFilter(key): void { + const filter = this.filters[key]; + this.filterFlags[key] = !this.filterFlags[key]; + if (this.filterFlags[key]) { + // remove any other flags in the same toggle group + if (filter.toggle) { + this.activeFilters.forEach(f => { + if (this.filters[f].toggle === filter.toggle) { + this.filterFlags[f] = false; + } + }); + this.activeFilters = this.activeFilters.filter(f => this.filters[f].toggle !== filter.toggle); + } + // add new active filter + this.activeFilters.push(key); + } else { + // remove active filter + this.activeFilters = this.activeFilters.filter(f => f != key); + } + this.onFilterChanged.emit(this.getBooleanFlags()); + } + + getBooleanFlags(): bigint | null { + let flags = 0n; + for (const key of Object.keys(this.filterFlags)) { + if (this.filterFlags[key]) { + flags |= this.filters[key].flag; + } + } + return flags || null; + } + + @HostListener('document:click', ['$event']) + onClick(event): boolean { + // click away from menu + if (!event.target.closest('button')) { + this.menuOpen = false; + } + return true; + } +} \ No newline at end of file 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 a625a0385..9f5e7cb47 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 @@ -13,5 +13,6 @@ [auditEnabled]="auditHighlighting" [blockConversion]="blockConversion" > + 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 1fc173a2d..716ba540e 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 @@ -8,6 +8,19 @@ import { Color, Position } from './sprite-types'; import { Price } from '../../services/price.service'; import { StateService } from '../../services/state.service'; import { Subscription } from 'rxjs'; +import { defaultColorFunction, setOpacity, defaultFeeColors, defaultAuditFeeColors, defaultMarginalFeeColors, defaultAuditColors } from './utils'; + +const unmatchedOpacity = 0.2; +const unmatchedFeeColors = defaultFeeColors.map(c => setOpacity(c, unmatchedOpacity)); +const unmatchedAuditFeeColors = defaultAuditFeeColors.map(c => setOpacity(c, unmatchedOpacity)); +const unmatchedMarginalFeeColors = defaultMarginalFeeColors.map(c => setOpacity(c, unmatchedOpacity)); +const unmatchedAuditColors = { + censored: setOpacity(defaultAuditColors.censored, unmatchedOpacity), + missing: setOpacity(defaultAuditColors.missing, unmatchedOpacity), + added: setOpacity(defaultAuditColors.added, unmatchedOpacity), + selected: setOpacity(defaultAuditColors.selected, unmatchedOpacity), + accelerated: setOpacity(defaultAuditColors.accelerated, unmatchedOpacity), +}; @Component({ selector: 'app-block-overview-graph', @@ -26,6 +39,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @Input() mirrorTxid: string | void; @Input() unavailable: boolean = false; @Input() auditHighlighting: boolean = false; + @Input() showFilters: boolean = false; + @Input() filterFlags: bigint | null = null; @Input() blockConversion: Price; @Input() overrideColors: ((tx: TxView) => Color) | null = null; @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>(); @@ -92,7 +107,18 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (changes.auditHighlighting) { this.setHighlightingEnabled(this.auditHighlighting); } - if (changes.overrideColor) { + if (changes.overrideColor && this.scene) { + this.scene.setColorFunction(this.overrideColors); + } + if ((changes.filterFlags || changes.showFilters) && this.scene) { + this.setFilterFlags(this.filterFlags); + } + } + + setFilterFlags(flags: bigint | null): void { + if (flags != null) { + this.scene.setColorFunction(this.getFilterColorFunction(flags)); + } else { this.scene.setColorFunction(this.overrideColors); } } @@ -374,6 +400,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On onPointerMove(event) { if (event.target === this.canvas.nativeElement) { this.setPreviewTx(event.offsetX, event.offsetY, false); + } else { + this.onPointerLeave(event); } } @@ -474,6 +502,22 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On onTxHover(hoverId: string) { this.txHoverEvent.emit(hoverId); } + + getFilterColorFunction(flags: bigint): ((tx: TxView) => Color) { + return (tx: TxView) => { + if ((tx.bigintFlags & flags) === flags) { + return defaultColorFunction(tx); + } else { + return defaultColorFunction( + tx, + unmatchedFeeColors, + unmatchedAuditFeeColors, + unmatchedMarginalFeeColors, + unmatchedAuditColors + ); + } + }; + } } // WebGL shader attributes 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 77b7c2e05..cb589527d 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -2,19 +2,7 @@ import { FastVertexArray } from './fast-vertex-array'; import TxView from './tx-view'; import { TransactionStripped } from '../../interfaces/websocket.interface'; import { Color, Position, Square, ViewUpdateParams } from './sprite-types'; -import { feeLevels, mempoolFeeColors } from '../../app.constants'; -import { darken, desaturate, hexToColor } from './utils'; - -const feeColors = mempoolFeeColors.map(hexToColor); -const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); -const marginalFeeColors = feeColors.map((color) => darken(desaturate(color, 0.8), 1.1)); -const auditColors = { - censored: hexToColor('f344df'), - missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), - added: hexToColor('0099ff'), - selected: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7), - accelerated: hexToColor('8F5FF6'), -}; +import { defaultColorFunction } from './utils'; export default class BlockScene { scene: { count: number, offset: { x: number, y: number}}; @@ -27,6 +15,7 @@ export default class BlockScene { configAnimationOffset: number | null; animationOffset: number; highlightingEnabled: boolean; + filterFlags: bigint | null = 0b00000100_00000000_00000000_00000000n; width: number; height: number; gridWidth: number; @@ -78,7 +67,7 @@ export default class BlockScene { } setColorFunction(colorFunction: ((tx: TxView) => Color) | null): void { - this.getColor = colorFunction; + this.getColor = colorFunction || defaultColorFunction; this.dirty = true; if (this.initialised && this.scene) { this.updateColors(performance.now(), 50); @@ -277,6 +266,20 @@ export default class BlockScene { this.animateUntil = Math.max(this.animateUntil, tx.update(update)); } + private updateTxColor(tx: TxView, startTime: number, delay: number, animate: boolean = true, duration?: number): void { + if (tx.dirty || this.dirty) { + const txColor = this.getColor(tx); + this.applyTxUpdate(tx, { + display: { + color: txColor + }, + duration: animate ? (duration || this.animationDuration) : 1, + start: startTime, + delay: animate ? delay : 0, + }); + } + } + private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left', animate: boolean = true): void { if (tx.dirty || this.dirty) { this.saveGridToScreenPosition(tx); @@ -325,7 +328,7 @@ export default class BlockScene { } else { this.applyTxUpdate(tx, { display: { - position: tx.screenPosition + position: tx.screenPosition, }, duration: animate ? this.animationDuration : 0, minDuration: animate ? (this.animationDuration / 2) : 0, @@ -903,49 +906,4 @@ class BlockLayout { function feeRateDescending(a: TxView, b: TxView) { return b.feerate - a.feerate; -} - -function defaultColorFunction(tx: TxView): Color { - const rate = tx.fee / tx.vsize; // color by simple single-tx fee rate - const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1; - const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1]; - // Normal mode - if (!tx.scene?.highlightingEnabled) { - if (tx.acc) { - return auditColors.accelerated; - } else { - return feeLevelColor; - } - return feeLevelColor; - } - // Block audit - switch(tx.status) { - case 'censored': - return auditColors.censored; - case 'missing': - case 'sigop': - case 'rbf': - return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; - case 'fresh': - case 'freshcpfp': - return auditColors.missing; - case 'added': - return auditColors.added; - case 'selected': - return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; - case 'accelerated': - return auditColors.accelerated; - case 'found': - if (tx.context === 'projected') { - return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; - } else { - return feeLevelColor; - } - default: - if (tx.acc) { - return auditColors.accelerated; - } else { - return feeLevelColor; - } - } } \ No newline at end of file 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 4e2d855e6..da36b9880 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -1,9 +1,9 @@ import TxSprite from './tx-sprite'; import { FastVertexArray } from './fast-vertex-array'; -import { TransactionStripped } from '../../interfaces/websocket.interface'; import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types'; import { hexToColor } from './utils'; import BlockScene from './block-scene'; +import { TransactionStripped } from '../../interfaces/node-api.interface'; const hoverTransitionTime = 300; const defaultHoverColor = hexToColor('1bd8f4'); @@ -29,6 +29,7 @@ export default class TxView implements TransactionStripped { feerate: number; acc?: boolean; rate?: number; + bigintFlags?: bigint | null = 0b00000100_00000000_00000000_00000000n; status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'rbf' | 'accelerated'; context?: 'projected' | 'actual'; scene?: BlockScene; @@ -57,6 +58,7 @@ export default class TxView implements TransactionStripped { this.acc = tx.acc; this.rate = tx.rate; this.status = tx.status; + this.bigintFlags = tx.flags ? BigInt(tx.flags) : 0n; this.initialised = false; this.vertexArray = scene.vertexArray; diff --git a/frontend/src/app/components/block-overview-graph/utils.ts b/frontend/src/app/components/block-overview-graph/utils.ts index a0bb8e868..9c800ad85 100644 --- a/frontend/src/app/components/block-overview-graph/utils.ts +++ b/frontend/src/app/components/block-overview-graph/utils.ts @@ -1,4 +1,6 @@ +import { feeLevels, mempoolFeeColors } from '../../app.constants'; import { Color } from './sprite-types'; +import TxView from './tx-view'; export function hexToColor(hex: string): Color { return { @@ -25,5 +27,75 @@ export function darken(color: Color, amount: number): Color { g: color.g * amount, b: color.b * amount, a: color.a, + }; +} + +export function setOpacity(color: Color, opacity: number): Color { + return { + ...color, + a: opacity + }; +} + +// precomputed colors +export const defaultFeeColors = mempoolFeeColors.map(hexToColor); +export const defaultAuditFeeColors = defaultFeeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); +export const defaultMarginalFeeColors = defaultFeeColors.map((color) => darken(desaturate(color, 0.8), 1.1)); +export const defaultAuditColors = { + censored: hexToColor('f344df'), + missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), + added: hexToColor('0099ff'), + selected: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7), + accelerated: hexToColor('8F5FF6'), +}; + +export function defaultColorFunction( + tx: TxView, + feeColors: Color[] = defaultFeeColors, + auditFeeColors: Color[] = defaultAuditFeeColors, + marginalFeeColors: Color[] = defaultMarginalFeeColors, + auditColors: { [status: string]: Color } = defaultAuditColors +): Color { + const rate = tx.fee / tx.vsize; // color by simple single-tx fee rate + const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1; + const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1]; + // Normal mode + if (!tx.scene?.highlightingEnabled) { + if (tx.acc) { + return auditColors.accelerated; + } else { + return feeLevelColor; + } + return feeLevelColor; + } + // Block audit + switch(tx.status) { + case 'censored': + return auditColors.censored; + case 'missing': + case 'sigop': + case 'rbf': + return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; + case 'fresh': + case 'freshcpfp': + return auditColors.missing; + case 'added': + return auditColors.added; + case 'selected': + return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; + case 'accelerated': + return auditColors.accelerated; + case 'found': + if (tx.context === 'projected') { + return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; + } else { + return feeLevelColor; + } + default: + if (tx.acc) { + return auditColors.accelerated; + } else { + return feeLevelColor; + } } } \ No newline at end of file diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts index 65d0f984c..a6e2a2697 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts @@ -1,7 +1,7 @@ import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core'; -import { TransactionStripped } from '../../interfaces/websocket.interface'; import { Position } from '../../components/block-overview-graph/sprite-types.js'; import { Price } from '../../services/price.service'; +import { TransactionStripped } from '../../interfaces/node-api.interface.js'; @Component({ selector: 'app-block-overview-tooltip', 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 1e0cba48c..85e7eebb1 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 @@ -5,6 +5,7 @@ [blockLimit]="stateService.blockVSize" [orientation]="timeLtr ? 'right' : 'left'" [flip]="true" + [showFilters]="showFilters" [overrideColors]="overrideColors" (txClickEvent)="onTxClick($event)" > 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 09eac989e..4beda043a 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 @@ -18,6 +18,7 @@ import TxView from '../block-overview-graph/tx-view'; }) export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { @Input() index: number; + @Input() showFilters: boolean = false; @Input() overrideColors: ((tx: TxView) => Color) | null = null; @Output() txPreviewEvent = new EventEmitter(); diff --git a/frontend/src/app/components/mempool-block-view/mempool-block-view.component.html b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.html index 9d51ff4e9..2fafb31cd 100644 --- a/frontend/src/app/components/mempool-block-view/mempool-block-view.component.html +++ b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.html @@ -1,5 +1,5 @@
- +
\ No newline at end of file diff --git a/frontend/src/app/components/mempool-block-view/mempool-block-view.component.ts b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.ts index ebeb0801c..a671033cf 100644 --- a/frontend/src/app/components/mempool-block-view/mempool-block-view.component.ts +++ b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.ts @@ -27,6 +27,7 @@ export class MempoolBlockViewComponent implements OnInit, OnDestroy { autofit: boolean = false; resolution: number = 80; index: number = 0; + filterFlags: bigint | null = 0n; routeParamsSubscription: Subscription; queryParamsSubscription: Subscription; @@ -38,6 +39,8 @@ export class MempoolBlockViewComponent implements OnInit, OnDestroy { ) { } ngOnInit(): void { + window['setFlags'] = this.setFilterFlags.bind(this); + this.websocketService.want(['blocks', 'mempool-blocks']); this.routeParamsSubscription = this.route.paramMap @@ -82,4 +85,8 @@ export class MempoolBlockViewComponent implements OnInit, OnDestroy { this.routeParamsSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe(); } + + setFilterFlags(flags: bigint | null) { + this.filterFlags = flags; + } } diff --git a/frontend/src/app/components/mempool-block/mempool-block.component.html b/frontend/src/app/components/mempool-block/mempool-block.component.html index b089a6d74..d2aa1aed2 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.html +++ b/frontend/src/app/components/mempool-block/mempool-block.component.html @@ -46,7 +46,9 @@
- +
+ +
diff --git a/frontend/src/app/components/mempool-block/mempool-block.component.ts b/frontend/src/app/components/mempool-block/mempool-block.component.ts index c11bedacd..bb6e7791f 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.ts +++ b/frontend/src/app/components/mempool-block/mempool-block.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; +import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { StateService } from '../../services/state.service'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { switchMap, map, tap, filter } from 'rxjs/operators'; @@ -28,6 +28,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { public stateService: StateService, private seoService: SeoService, private websocketService: WebsocketService, + private cd: ChangeDetectorRef, ) { this.webGlEnabled = detectWebGL(); } diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 862272330..e225eb758 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -180,6 +180,7 @@ export interface TransactionStripped { value: number; rate?: number; // effective fee rate acc?: boolean; + flags?: number | null; status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'rbf' | 'accelerated'; context?: 'projected' | 'actual'; } diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 1d0414de7..20bc42bde 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -90,6 +90,7 @@ export interface TransactionStripped { value: number; acc?: boolean; // is accelerated? rate?: number; // effective fee rate + flags?: number; status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'rbf' | 'accelerated'; context?: 'projected' | 'actual'; } diff --git a/frontend/src/app/shared/filters.utils.ts b/frontend/src/app/shared/filters.utils.ts new file mode 100644 index 000000000..0b652a192 --- /dev/null +++ b/frontend/src/app/shared/filters.utils.ts @@ -0,0 +1,88 @@ +export interface Filter { + key: string, + label: string, + flag: bigint, + toggle?: string, + group?: string, + important?: boolean, +} + +// binary flags for transaction classification +export const TransactionFlags = { + // features + rbf: 0b00000001n, + no_rbf: 0b00000010n, + v1: 0b00000100n, + v2: 0b00001000n, + multisig: 0b00010000n, + // address types + p2pk: 0b00000001_00000000n, + p2ms: 0b00000010_00000000n, + p2pkh: 0b00000100_00000000n, + p2sh: 0b00001000_00000000n, + p2wpkh: 0b00010000_00000000n, + p2wsh: 0b00100000_00000000n, + p2tr: 0b01000000_00000000n, + // behavior + cpfp_parent: 0b00000001_00000000_00000000n, + cpfp_child: 0b00000010_00000000_00000000n, + replacement: 0b00000100_00000000_00000000n, + // data + op_return: 0b00000001_00000000_00000000_00000000n, + fake_pubkey: 0b00000010_00000000_00000000_00000000n, + inscription: 0b00000100_00000000_00000000_00000000n, + // heuristics + coinjoin: 0b00000001_00000000_00000000_00000000_00000000n, + consolidation: 0b00000010_00000000_00000000_00000000_00000000n, + batch_payout: 0b00000100_00000000_00000000_00000000_00000000n, + // sighash + sighash_all: 0b00000001_00000000_00000000_00000000_00000000_00000000n, + sighash_none: 0b00000010_00000000_00000000_00000000_00000000_00000000n, + sighash_single: 0b00000100_00000000_00000000_00000000_00000000_00000000n, + sighash_default:0b00001000_00000000_00000000_00000000_00000000_00000000n, + sighash_acp: 0b00010000_00000000_00000000_00000000_00000000_00000000n, +}; + +export const TransactionFilters: { [key: string]: Filter } = { + /* features */ + rbf: { key: 'rbf', label: 'RBF enabled', flag: TransactionFlags.rbf, toggle: 'rbf', important: true }, + no_rbf: { key: 'no_rbf', label: 'RBF disabled', flag: TransactionFlags.no_rbf, toggle: 'rbf', important: true }, + v1: { key: 'v1', label: 'Version 1', flag: TransactionFlags.v1, toggle: 'version' }, + v2: { key: 'v2', label: 'Version 2', flag: TransactionFlags.v2, toggle: 'version' }, + // multisig: { key: 'multisig', label: 'Multisig', flag: TransactionFlags.multisig }, + /* address types */ + p2pk: { key: 'p2pk', label: 'P2PK', flag: TransactionFlags.p2pk, important: true }, + p2ms: { key: 'p2ms', label: 'Bare multisig', flag: TransactionFlags.p2ms, important: true }, + p2pkh: { key: 'p2pkh', label: 'P2PKH', flag: TransactionFlags.p2pkh, important: true }, + p2sh: { key: 'p2sh', label: 'P2SH', flag: TransactionFlags.p2sh, important: true }, + p2wpkh: { key: 'p2wpkh', label: 'P2WPKH', flag: TransactionFlags.p2wpkh, important: true }, + p2wsh: { key: 'p2wsh', label: 'P2WSH', flag: TransactionFlags.p2wsh, important: true }, + p2tr: { key: 'p2tr', label: 'Taproot', flag: TransactionFlags.p2tr, important: true }, + /* behavior */ + cpfp_parent: { key: 'cpfp_parent', label: 'Paid for by child', flag: TransactionFlags.cpfp_parent, important: true }, + cpfp_child: { key: 'cpfp_child', label: 'Pays for parent', flag: TransactionFlags.cpfp_child, important: true }, + replacement: { key: 'replacement', label: 'Replacement', flag: TransactionFlags.replacement, important: true }, + /* data */ + op_return: { key: 'op_return', label: 'OP_RETURN', flag: TransactionFlags.op_return, important: true }, + fake_pubkey: { key: 'fake_pubkey', label: 'Fake pubkey', flag: TransactionFlags.fake_pubkey }, + inscription: { key: 'inscription', label: 'Inscription', flag: TransactionFlags.inscription, important: true }, + /* heuristics */ + coinjoin: { key: 'coinjoin', label: 'Coinjoin', flag: TransactionFlags.coinjoin, important: true }, + consolidation: { key: 'consolidation', label: 'Consolidation', flag: TransactionFlags.consolidation }, + batch_payout: { key: 'batch_payout', label: 'Batch payment', flag: TransactionFlags.batch_payout }, + /* sighash */ + sighash_all: { key: 'sighash_all', label: 'sighash_all', flag: TransactionFlags.sighash_all }, + sighash_none: { key: 'sighash_none', label: 'sighash_none', flag: TransactionFlags.sighash_none }, + sighash_single: { key: 'sighash_single', label: 'sighash_single', flag: TransactionFlags.sighash_single }, + sighash_default: { key: 'sighash_default', label: 'sighash_default', flag: TransactionFlags.sighash_default }, + sighash_acp: { key: 'sighash_acp', label: 'sighash_anyonecanpay', flag: TransactionFlags.sighash_acp }, +}; + +export const FilterGroups: { label: string, filters: Filter[]}[] = [ + { label: 'Features', filters: ['rbf', 'no_rbf', 'v1', 'v2', 'multisig'] }, + { label: 'Address Types', filters: ['p2pk', 'p2ms', 'p2pkh', 'p2sh', 'p2wpkh', 'p2wsh', 'p2tr'] }, + { label: 'Behavior', filters: ['cpfp_parent', 'cpfp_child', 'replacement'] }, + { label: 'Data', filters: ['op_return', 'fake_pubkey', 'inscription'] }, + { label: 'Heuristics', filters: ['coinjoin', 'consolidation', 'batch_payout'] }, + { label: 'Sighash Flags', filters: ['sighash_all', 'sighash_none', 'sighash_single', 'sighash_default', 'sighash_acp'] }, +].map(group => ({ label: group.label, filters: group.filters.map(filter => TransactionFilters[filter] || null).filter(f => f != null) })); \ No newline at end of file diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 52123f995..9bcfb932c 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -44,6 +44,7 @@ import { StartComponent } from '../components/start/start.component'; import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; +import { BlockFiltersComponent } from '../components/block-filters/block-filters.component'; import { AddressComponent } from '../components/address/address.component'; import { SearchFormComponent } from '../components/search-form/search-form.component'; import { AddressLabelsComponent } from '../components/address-labels/address-labels.component'; @@ -141,6 +142,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir StartComponent, BlockOverviewGraphComponent, BlockOverviewTooltipComponent, + BlockFiltersComponent, TransactionsListComponent, AddressComponent, SearchFormComponent, @@ -266,6 +268,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir StartComponent, BlockOverviewGraphComponent, BlockOverviewTooltipComponent, + BlockFiltersComponent, TransactionsListComponent, AddressComponent, SearchFormComponent,