From 173bc127cb29e74468a85e9350eb426bd9554ad0 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 5 Dec 2023 06:54:31 +0000 Subject: [PATCH 1/9] Block viz filters proof of concept --- backend/src/api/common.ts | 107 +++++++++++++++++- backend/src/api/mempool-blocks.ts | 7 +- backend/src/api/mempool.ts | 3 + backend/src/mempool.interfaces.ts | 45 +++++++- .../block-overview-graph.component.ts | 9 ++ .../block-overview-graph/block-scene.ts | 17 ++- .../block-overview-graph/tx-view.ts | 4 +- .../block-overview-tooltip.component.ts | 2 +- .../mempool-block-view.component.html | 2 +- .../mempool-block-view.component.ts | 7 ++ .../mempool-block.component.html | 2 +- .../mempool-block/mempool-block.component.ts | 10 +- .../src/app/interfaces/node-api.interface.ts | 36 ++++++ .../src/app/interfaces/websocket.interface.ts | 1 + 14 files changed, 239 insertions(+), 13 deletions(-) diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index b6f8ab657..9bae2d906 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -1,9 +1,11 @@ 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'; export class Common { static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ? '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49' @@ -138,6 +140,109 @@ export class Common { return matches; } + 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 getTransactionFlags(tx: TransactionExtended): number { + let flags = 0n; + if (tx.version === 1) { + flags |= TransactionFlags.v1; + } else if (tx.version === 2) { + flags |= TransactionFlags.v2; + } + 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; + flags = this.setSighashFlags(flags, vin.scriptsig); + } 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; + } + inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1; + } + if (rbf) { + flags |= TransactionFlags.rbf; + } else { + flags |= TransactionFlags.no_rbf; + } + for (const vout of tx.vout) { + switch (vout.scriptpubkey_type) { + case 'p2pk': flags |= TransactionFlags.p2pk; break; + case 'multisig': { + flags |= TransactionFlags.p2ms; + // TODO - detect fake multisig data embedding + } 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; + } + outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1; + } + 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) + if (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/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..f50274304 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_multisig: 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/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..5eaee25a1 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 @@ -26,6 +26,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @Input() mirrorTxid: string | void; @Input() unavailable: boolean = false; @Input() auditHighlighting: boolean = false; + @Input() filterFlags: bigint | null = 0b00000100_00000000_00000000_00000000n; @Input() blockConversion: Price; @Input() overrideColors: ((tx: TxView) => Color) | null = null; @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>(); @@ -462,6 +463,14 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } } + setFilterFlags(flags: bigint | null): void { + if (this.scene) { + console.log('setting filter flags to ', this.filterFlags.toString(2)); + this.scene.setFilterFlags(flags); + this.start(); + } + } + onTxClick(cssX: number, cssY: number, keyModifier: boolean = false) { const x = cssX * window.devicePixelRatio; const y = cssY * window.devicePixelRatio; 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..b6cf0ce59 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -27,6 +27,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; @@ -277,6 +278,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 = tx.getColor(); + 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 +340,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, 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-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-view/mempool-block-view.component.html b/frontend/src/app/components/mempool-block-view/mempool-block-view.component.html index 9d51ff4e9..33ccf439b 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..af9225fe6 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,7 @@
- +
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..4b8d4de66 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'; @@ -21,6 +21,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { mempoolBlockTransactions$: Observable; ordinal$: BehaviorSubject = new BehaviorSubject(''); previewTx: TransactionStripped | void; + filterFlags: bigint | null = 0n; webGlEnabled: boolean; constructor( @@ -28,11 +29,13 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { public stateService: StateService, private seoService: SeoService, private websocketService: WebsocketService, + private cd: ChangeDetectorRef, ) { this.webGlEnabled = detectWebGL(); } ngOnInit(): void { + window['setFlags'] = this.setFilterFlags.bind(this); this.websocketService.want(['blocks', 'mempool-blocks']); this.mempoolBlock$ = this.route.paramMap @@ -89,6 +92,11 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { setTxPreview(event: TransactionStripped | void): void { this.previewTx = event; } + + setFilterFlags(flags: bigint | null) { + this.filterFlags = flags; + this.cd.markForCheck(); + } } function detectWebGL() { diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 862272330..3075491a8 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -180,10 +180,46 @@ 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'; } +// 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_multisig: 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 RbfTransaction extends TransactionStripped { rbf?: boolean; mined?: boolean, 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'; } From e12f43e741665c282fb3d1bf3a702c351ad76cfb Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 13 Dec 2023 10:56:33 +0000 Subject: [PATCH 2/9] Add sighash filter flags --- backend/src/api/common.ts | 88 +++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 9bae2d906..00f4328ce 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -140,6 +140,65 @@ 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 === 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; @@ -159,18 +218,16 @@ export class Common { } 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; + rbf = true; } switch (vin.prevout?.scriptpubkey_type) { - case 'p2pk': { - flags |= TransactionFlags.p2pk; - flags = this.setSighashFlags(flags, vin.scriptsig); - } break; + case 'p2pk': flags |= TransactionFlags.p2pk; break; case 'multisig': flags |= TransactionFlags.p2ms; break; case 'p2pkh': flags |= TransactionFlags.p2pkh; break; case 'p2sh': flags |= TransactionFlags.p2sh; break; @@ -186,6 +243,19 @@ export class Common { } } 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_asm) { + flags |= this.setLegacySighashFlags(flags, vin.scriptsig_asm); + } + + 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) { @@ -207,6 +277,9 @@ export class Common { 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 (tx.ancestors?.length) { @@ -219,8 +292,9 @@ export class Common { 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) - if (tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) { + // (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 From 24dbe5d4ee45bf6fe7701d848c317392606660e3 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 13 Dec 2023 10:59:28 +0000 Subject: [PATCH 3/9] Add block viz filter UI --- .../block-filters.component.html | 22 ++++ .../block-filters.component.scss | 104 ++++++++++++++++++ .../block-filters/block-filters.component.ts | 65 +++++++++++ .../block-overview-graph.component.html | 1 + .../block-overview-graph.component.ts | 55 +++++++-- .../block-overview-graph/block-scene.ts | 63 +---------- .../components/block-overview-graph/utils.ts | 72 ++++++++++++ .../mempool-block-overview.component.html | 1 + .../mempool-block-overview.component.ts | 1 + .../mempool-block-view.component.html | 2 +- .../mempool-block.component.html | 4 +- .../mempool-block/mempool-block.component.ts | 7 -- .../src/app/interfaces/node-api.interface.ts | 35 ------ frontend/src/app/shared/filters.utils.ts | 87 +++++++++++++++ frontend/src/app/shared/shared.module.ts | 3 + 15 files changed, 408 insertions(+), 114 deletions(-) create mode 100644 frontend/src/app/components/block-filters/block-filters.component.html create mode 100644 frontend/src/app/components/block-filters/block-filters.component.scss create mode 100644 frontend/src/app/components/block-filters/block-filters.component.ts create mode 100644 frontend/src/app/shared/filters.utils.ts 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..ff86e6b3b --- /dev/null +++ b/frontend/src/app/components/block-filters/block-filters.component.html @@ -0,0 +1,22 @@ +
+
+ +
+ + + +
+
+
+ +
{{ 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..ee9e7f4d3 --- /dev/null +++ b/frontend/src/app/components/block-filters/block-filters.component.scss @@ -0,0 +1,104 @@ +.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; + } +} \ 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..fc154bb69 --- /dev/null +++ b/frontend/src/app/components/block-filters/block-filters.component.ts @@ -0,0 +1,65 @@ +import { Component, OnChanges, EventEmitter, Output, SimpleChanges, HostListener } from '@angular/core'; +import { FilterGroups, TransactionFilters, Filter, TransactionFlags } from '../../shared/filters.utils'; + + +@Component({ + selector: 'app-block-filters', + templateUrl: './block-filters.component.html', + styleUrls: ['./block-filters.component.scss'], +}) +export class BlockFiltersComponent implements OnChanges { + @Output() onFilterChanged: EventEmitter = new EventEmitter(); + + filters = TransactionFilters; + filterGroups = FilterGroups; + activeFilters: string[] = []; + filterFlags: { [key: string]: boolean } = {}; + menuOpen: boolean = false; + + constructor() {} + + ngOnChanges(changes: SimpleChanges): void { + + } + + 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..9f7408323 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 5eaee25a1..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,7 +39,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @Input() mirrorTxid: string | void; @Input() unavailable: boolean = false; @Input() auditHighlighting: boolean = false; - @Input() filterFlags: bigint | null = 0b00000100_00000000_00000000_00000000n; + @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}>(); @@ -93,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); } } @@ -375,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); } } @@ -463,14 +490,6 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } } - setFilterFlags(flags: bigint | null): void { - if (this.scene) { - console.log('setting filter flags to ', this.filterFlags.toString(2)); - this.scene.setFilterFlags(flags); - this.start(); - } - } - onTxClick(cssX: number, cssY: number, keyModifier: boolean = false) { const x = cssX * window.devicePixelRatio; const y = cssY * window.devicePixelRatio; @@ -483,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 b6cf0ce59..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}}; @@ -79,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); @@ -280,7 +268,7 @@ export default class BlockScene { private updateTxColor(tx: TxView, startTime: number, delay: number, animate: boolean = true, duration?: number): void { if (tx.dirty || this.dirty) { - const txColor = tx.getColor(); + const txColor = this.getColor(tx); this.applyTxUpdate(tx, { display: { color: txColor @@ -918,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/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/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 33ccf439b..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/mempool-block.component.html b/frontend/src/app/components/mempool-block/mempool-block.component.html index af9225fe6..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 4b8d4de66..bb6e7791f 100644 --- a/frontend/src/app/components/mempool-block/mempool-block.component.ts +++ b/frontend/src/app/components/mempool-block/mempool-block.component.ts @@ -21,7 +21,6 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { mempoolBlockTransactions$: Observable; ordinal$: BehaviorSubject = new BehaviorSubject(''); previewTx: TransactionStripped | void; - filterFlags: bigint | null = 0n; webGlEnabled: boolean; constructor( @@ -35,7 +34,6 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { } ngOnInit(): void { - window['setFlags'] = this.setFilterFlags.bind(this); this.websocketService.want(['blocks', 'mempool-blocks']); this.mempoolBlock$ = this.route.paramMap @@ -92,11 +90,6 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { setTxPreview(event: TransactionStripped | void): void { this.previewTx = event; } - - setFilterFlags(flags: bigint | null) { - this.filterFlags = flags; - this.cd.markForCheck(); - } } function detectWebGL() { diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 3075491a8..e225eb758 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -185,41 +185,6 @@ export interface TransactionStripped { context?: 'projected' | 'actual'; } -// 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_multisig: 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 RbfTransaction extends TransactionStripped { rbf?: boolean; mined?: boolean, diff --git a/frontend/src/app/shared/filters.utils.ts b/frontend/src/app/shared/filters.utils.ts new file mode 100644 index 000000000..f7e2f91c1 --- /dev/null +++ b/frontend/src/app/shared/filters.utils.ts @@ -0,0 +1,87 @@ +export interface Filter { + key: string, + label: string, + flag: bigint, + toggle?: string, + group?: string, +} + +// 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_multisig: 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' }, + no_rbf: { key: 'no_rbf', label: 'RBF disabled', flag: TransactionFlags.no_rbf, toggle: 'rbf' }, + 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 }, + p2ms: { key: 'p2ms', label: 'Bare multisig', flag: TransactionFlags.p2ms }, + p2pkh: { key: 'p2pkh', label: 'P2PKH', flag: TransactionFlags.p2pkh }, + p2sh: { key: 'p2sh', label: 'P2SH', flag: TransactionFlags.p2sh }, + p2wpkh: { key: 'p2wpkh', label: 'P2WPKH', flag: TransactionFlags.p2wpkh }, + p2wsh: { key: 'p2wsh', label: 'P2WSH', flag: TransactionFlags.p2wsh }, + p2tr: { key: 'p2tr', label: 'Taproot', flag: TransactionFlags.p2tr }, + // behavior + cpfp_parent: { key: 'cpfp_parent', label: 'Paid for by child', flag: TransactionFlags.cpfp_parent }, + cpfp_child: { key: 'cpfp_child', label: 'Pays for parent', flag: TransactionFlags.cpfp_child }, + replacement: { key: 'replacement', label: 'Replacement', flag: TransactionFlags.replacement }, + // data + op_return: { key: 'op_return', label: 'OP_RETURN', flag: TransactionFlags.op_return }, + // fake_multisig: { key: 'fake_multisig', label: 'Fake multisig', flag: TransactionFlags.fake_multisig }, + inscription: { key: 'inscription', label: 'Inscription', flag: TransactionFlags.inscription }, + // heuristics + coinjoin: { key: 'coinjoin', label: 'Coinjoin', flag: TransactionFlags.coinjoin }, + 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_multisig', '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, From 5777561744d8e0525c8f397ff1cd345c8cfe9d23 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 13 Dec 2023 11:04:45 +0000 Subject: [PATCH 4/9] Tidy up block filter code, disable multisig flag --- .../block-filters/block-filters.component.ts | 12 +++--------- frontend/src/app/shared/filters.utils.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/components/block-filters/block-filters.component.ts b/frontend/src/app/components/block-filters/block-filters.component.ts index fc154bb69..97b23a4db 100644 --- a/frontend/src/app/components/block-filters/block-filters.component.ts +++ b/frontend/src/app/components/block-filters/block-filters.component.ts @@ -1,5 +1,5 @@ -import { Component, OnChanges, EventEmitter, Output, SimpleChanges, HostListener } from '@angular/core'; -import { FilterGroups, TransactionFilters, Filter, TransactionFlags } from '../../shared/filters.utils'; +import { Component, EventEmitter, Output, HostListener } from '@angular/core'; +import { FilterGroups, TransactionFilters } from '../../shared/filters.utils'; @Component({ @@ -7,7 +7,7 @@ import { FilterGroups, TransactionFilters, Filter, TransactionFlags } from '../. templateUrl: './block-filters.component.html', styleUrls: ['./block-filters.component.scss'], }) -export class BlockFiltersComponent implements OnChanges { +export class BlockFiltersComponent { @Output() onFilterChanged: EventEmitter = new EventEmitter(); filters = TransactionFilters; @@ -16,12 +16,6 @@ export class BlockFiltersComponent implements OnChanges { filterFlags: { [key: string]: boolean } = {}; menuOpen: boolean = false; - constructor() {} - - ngOnChanges(changes: SimpleChanges): void { - - } - toggleFilter(key): void { const filter = this.filters[key]; this.filterFlags[key] = !this.filterFlags[key]; diff --git a/frontend/src/app/shared/filters.utils.ts b/frontend/src/app/shared/filters.utils.ts index f7e2f91c1..72cb5976a 100644 --- a/frontend/src/app/shared/filters.utils.ts +++ b/frontend/src/app/shared/filters.utils.ts @@ -43,13 +43,13 @@ export const TransactionFlags = { }; export const TransactionFilters: { [key: string]: Filter } = { - // features + /* features */ rbf: { key: 'rbf', label: 'RBF enabled', flag: TransactionFlags.rbf, toggle: 'rbf' }, no_rbf: { key: 'no_rbf', label: 'RBF disabled', flag: TransactionFlags.no_rbf, toggle: 'rbf' }, 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 + // multisig: { key: 'multisig', label: 'Multisig', flag: TransactionFlags.multisig }, + /* address types */ p2pk: { key: 'p2pk', label: 'P2PK', flag: TransactionFlags.p2pk }, p2ms: { key: 'p2ms', label: 'Bare multisig', flag: TransactionFlags.p2ms }, p2pkh: { key: 'p2pkh', label: 'P2PKH', flag: TransactionFlags.p2pkh }, @@ -57,19 +57,19 @@ export const TransactionFilters: { [key: string]: Filter } = { p2wpkh: { key: 'p2wpkh', label: 'P2WPKH', flag: TransactionFlags.p2wpkh }, p2wsh: { key: 'p2wsh', label: 'P2WSH', flag: TransactionFlags.p2wsh }, p2tr: { key: 'p2tr', label: 'Taproot', flag: TransactionFlags.p2tr }, - // behavior + /* behavior */ cpfp_parent: { key: 'cpfp_parent', label: 'Paid for by child', flag: TransactionFlags.cpfp_parent }, cpfp_child: { key: 'cpfp_child', label: 'Pays for parent', flag: TransactionFlags.cpfp_child }, replacement: { key: 'replacement', label: 'Replacement', flag: TransactionFlags.replacement }, - // data + /* data */ op_return: { key: 'op_return', label: 'OP_RETURN', flag: TransactionFlags.op_return }, // fake_multisig: { key: 'fake_multisig', label: 'Fake multisig', flag: TransactionFlags.fake_multisig }, inscription: { key: 'inscription', label: 'Inscription', flag: TransactionFlags.inscription }, - // heuristics + /* heuristics */ coinjoin: { key: 'coinjoin', label: 'Coinjoin', flag: TransactionFlags.coinjoin }, consolidation: { key: 'consolidation', label: 'Consolidation', flag: TransactionFlags.consolidation }, batch_payout: { key: 'batch_payout', label: 'Batch payment', flag: TransactionFlags.batch_payout }, - // sighash + /* 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 }, From c019355c9fb7a17b12efb453ca2c600f8d46ef1b Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 13 Dec 2023 11:29:10 +0000 Subject: [PATCH 5/9] Adapt block filter UI for small screens --- .../block-filters.component.html | 11 +++++-- .../block-filters.component.scss | 24 ++++++++++++++ .../block-filters/block-filters.component.ts | 15 +++++++-- .../block-overview-graph.component.html | 2 +- frontend/src/app/shared/filters.utils.ts | 31 ++++++++++--------- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/components/block-filters/block-filters.component.html b/frontend/src/app/components/block-filters/block-filters.component.html index ff86e6b3b..90b66ddc3 100644 --- a/frontend/src/app/components/block-filters/block-filters.component.html +++ b/frontend/src/app/components/block-filters/block-filters.component.html @@ -1,4 +1,4 @@ -
+
-
+
{{ group.label }}
@@ -19,4 +19,11 @@
+
+ + + + + +
\ 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 index ee9e7f4d3..20b565293 100644 --- a/frontend/src/app/components/block-filters/block-filters.component.scss +++ b/frontend/src/app/components/block-filters/block-filters.component.scss @@ -101,4 +101,28 @@ 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 index 97b23a4db..ce0dd76ab 100644 --- a/frontend/src/app/components/block-filters/block-filters.component.ts +++ b/frontend/src/app/components/block-filters/block-filters.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Output, HostListener } from '@angular/core'; +import { Component, EventEmitter, Output, HostListener, Input, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core'; import { FilterGroups, TransactionFilters } from '../../shared/filters.utils'; @@ -7,7 +7,8 @@ import { FilterGroups, TransactionFilters } from '../../shared/filters.utils'; templateUrl: './block-filters.component.html', styleUrls: ['./block-filters.component.scss'], }) -export class BlockFiltersComponent { +export class BlockFiltersComponent implements OnChanges { + @Input() cssWidth: number = 800; @Output() onFilterChanged: EventEmitter = new EventEmitter(); filters = TransactionFilters; @@ -16,6 +17,16 @@ export class BlockFiltersComponent { 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]; 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 9f7408323..251b84a73 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,6 +13,6 @@ [auditEnabled]="auditHighlighting" [blockConversion]="blockConversion" > - +
diff --git a/frontend/src/app/shared/filters.utils.ts b/frontend/src/app/shared/filters.utils.ts index 72cb5976a..4a8cb6a15 100644 --- a/frontend/src/app/shared/filters.utils.ts +++ b/frontend/src/app/shared/filters.utils.ts @@ -4,6 +4,7 @@ export interface Filter { flag: bigint, toggle?: string, group?: string, + important?: boolean, } // binary flags for transaction classification @@ -44,29 +45,29 @@ export const TransactionFlags = { export const TransactionFilters: { [key: string]: Filter } = { /* features */ - rbf: { key: 'rbf', label: 'RBF enabled', flag: TransactionFlags.rbf, toggle: 'rbf' }, - no_rbf: { key: 'no_rbf', label: 'RBF disabled', flag: TransactionFlags.no_rbf, toggle: 'rbf' }, + 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 }, - p2ms: { key: 'p2ms', label: 'Bare multisig', flag: TransactionFlags.p2ms }, - p2pkh: { key: 'p2pkh', label: 'P2PKH', flag: TransactionFlags.p2pkh }, - p2sh: { key: 'p2sh', label: 'P2SH', flag: TransactionFlags.p2sh }, - p2wpkh: { key: 'p2wpkh', label: 'P2WPKH', flag: TransactionFlags.p2wpkh }, - p2wsh: { key: 'p2wsh', label: 'P2WSH', flag: TransactionFlags.p2wsh }, - p2tr: { key: 'p2tr', label: 'Taproot', flag: TransactionFlags.p2tr }, + 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 }, - cpfp_child: { key: 'cpfp_child', label: 'Pays for parent', flag: TransactionFlags.cpfp_child }, - replacement: { key: 'replacement', label: 'Replacement', flag: TransactionFlags.replacement }, + 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 }, + op_return: { key: 'op_return', label: 'OP_RETURN', flag: TransactionFlags.op_return, important: true }, // fake_multisig: { key: 'fake_multisig', label: 'Fake multisig', flag: TransactionFlags.fake_multisig }, - inscription: { key: 'inscription', label: 'Inscription', flag: TransactionFlags.inscription }, + inscription: { key: 'inscription', label: 'Inscription', flag: TransactionFlags.inscription, important: true }, /* heuristics */ - coinjoin: { key: 'coinjoin', label: 'Coinjoin', flag: TransactionFlags.coinjoin }, + 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 */ From ce195c913397dfca81a120f005b2e2175f19d238 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Wed, 13 Dec 2023 16:15:55 +0000 Subject: [PATCH 6/9] Fix ECDSA DER signature detection --- backend/src/api/common.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 00f4328ce..42dae7eb0 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -171,8 +171,8 @@ export class Common { // 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 === parseInt(w.slice(2, 4), 16) + 6) // second byte encodes the combined length of the R and S components + && ['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 ); } From 512589dc79fde70a1dec5f6ed443b4b1d84aa789 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 14 Dec 2023 11:26:17 +0000 Subject: [PATCH 7/9] Add fake pubkey filter --- backend/src/api/common.ts | 32 ++++++++-- backend/src/mempool.interfaces.ts | 2 +- backend/src/utils/secp256k1.ts | 74 ++++++++++++++++++++++++ frontend/src/app/shared/filters.utils.ts | 6 +- 4 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 backend/src/utils/secp256k1.ts diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 42dae7eb0..751bab5a3 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -6,6 +6,7 @@ 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' @@ -211,6 +212,15 @@ export class Common { } } + static isBurnKey(pubkey: string): boolean { + return [ + '022222222222222222222222222222222222222222222222222222222222222222', + '033333333333333333333333333333333333333333333333333333333333333333', + '020202020202020202020202020202020202020202020202020202020202020202', + '030303030303030303030303030303030303030303030303030303030303030303', + ].includes(pubkey); + } + static getTransactionFlags(tx: TransactionExtended): number { let flags = 0n; if (tx.version === 1) { @@ -249,8 +259,8 @@ export class Common { flags |= this.setSchnorrSighashFlags(flags, vin.witness); } else if (vin.witness) { flags |= this.setSegwitSighashFlags(flags, vin.witness); - } else if (vin.scriptsig_asm) { - flags |= this.setLegacySighashFlags(flags, vin.scriptsig_asm); + } else if (vin.scriptsig?.length) { + flags |= this.setLegacySighashFlags(flags, vin.scriptsig_asm || transactionUtils.convertScriptSigAsm(vin.scriptsig)); } if (vin.prevout?.scriptpubkey_address) { @@ -263,12 +273,23 @@ export class Common { } else { flags |= TransactionFlags.no_rbf; } + let hasFakePubkey = false; for (const vout of tx.vout) { switch (vout.scriptpubkey_type) { - case 'p2pk': flags |= TransactionFlags.p2pk; break; + 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; - // TODO - detect fake multisig data embedding + // 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; @@ -282,6 +303,9 @@ export class Common { } 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; } diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index f50274304..4a630f1e4 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -215,7 +215,7 @@ export const TransactionFlags = { replacement: 0b00000100_00000000_00000000n, // data op_return: 0b00000001_00000000_00000000_00000000n, - fake_multisig: 0b00000010_00000000_00000000_00000000n, + fake_pubkey: 0b00000010_00000000_00000000_00000000n, inscription: 0b00000100_00000000_00000000_00000000n, // heuristics coinjoin: 0b00000001_00000000_00000000_00000000_00000000n, 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/shared/filters.utils.ts b/frontend/src/app/shared/filters.utils.ts index 4a8cb6a15..0b652a192 100644 --- a/frontend/src/app/shared/filters.utils.ts +++ b/frontend/src/app/shared/filters.utils.ts @@ -29,7 +29,7 @@ export const TransactionFlags = { replacement: 0b00000100_00000000_00000000n, // data op_return: 0b00000001_00000000_00000000_00000000n, - fake_multisig: 0b00000010_00000000_00000000_00000000n, + fake_pubkey: 0b00000010_00000000_00000000_00000000n, inscription: 0b00000100_00000000_00000000_00000000n, // heuristics coinjoin: 0b00000001_00000000_00000000_00000000_00000000n, @@ -64,7 +64,7 @@ export const TransactionFilters: { [key: string]: Filter } = { 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_multisig: { key: 'fake_multisig', label: 'Fake multisig', flag: TransactionFlags.fake_multisig }, + 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 }, @@ -82,7 +82,7 @@ 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_multisig', 'inscription'] }, + { 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 From 16b9ca6c4096e76fbd73f3feba6b1ed662c0ab68 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 14 Dec 2023 13:30:19 +0000 Subject: [PATCH 8/9] Fix CI unit test circular dependency --- backend/src/api/fee-api.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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(); From 2e531413fa23b4ba93ff92877053bfb29b18aca3 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 14 Dec 2023 18:09:04 +0000 Subject: [PATCH 9/9] Disable filter UI on mined blocks --- .../block-overview-graph/block-overview-graph.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 251b84a73..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,6 +13,6 @@ [auditEnabled]="auditHighlighting" [blockConversion]="blockConversion" > - +