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 @@
+
0" [class.menu-open]="menuOpen" [class.small]="cssWidth < 500" [class.vsmall]="cssWidth < 400" [class.tiny]="cssWidth < 200">
+
+
+
+
+
+
+
+
+
+
+
\ 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,