Merge pull request #3353 from mempool/mononaut/mempool-block-animations

Improve mempool block animations
This commit is contained in:
softsimon 2023-03-18 12:46:20 +09:00 committed by GitHub
commit b78fdf5a23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 82 additions and 11 deletions

View File

@ -5,9 +5,9 @@ const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first
class Audit { class Audit {
auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended }) auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended })
: { censored: string[], added: string[], fresh: string[], score: number } { : { censored: string[], added: string[], fresh: string[], score: number, similarity: number } {
if (!projectedBlocks?.[0]?.transactionIds || !mempool) { if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
return { censored: [], added: [], fresh: [], score: 0 }; return { censored: [], added: [], fresh: [], score: 0, similarity: 1 };
} }
const matches: string[] = []; // present in both mined block and template const matches: string[] = []; // present in both mined block and template
@ -16,6 +16,8 @@ class Audit {
const isCensored = {}; // missing, without excuse const isCensored = {}; // missing, without excuse
const isDisplaced = {}; const isDisplaced = {};
let displacedWeight = 0; let displacedWeight = 0;
let matchedWeight = 0;
let projectedWeight = 0;
const inBlock = {}; const inBlock = {};
const inTemplate = {}; const inTemplate = {};
@ -38,11 +40,16 @@ class Audit {
isCensored[txid] = true; isCensored[txid] = true;
} }
displacedWeight += mempool[txid].weight; displacedWeight += mempool[txid].weight;
} else {
matchedWeight += mempool[txid].weight;
} }
projectedWeight += mempool[txid].weight;
inTemplate[txid] = true; inTemplate[txid] = true;
} }
displacedWeight += (4000 - transactions[0].weight); displacedWeight += (4000 - transactions[0].weight);
projectedWeight += transactions[0].weight;
matchedWeight += transactions[0].weight;
// we can expect an honest miner to include 'displaced' transactions in place of recent arrivals and censored txs // we can expect an honest miner to include 'displaced' transactions in place of recent arrivals and censored txs
// these displaced transactions should occupy the first N weight units of the next projected block // these displaced transactions should occupy the first N weight units of the next projected block
@ -121,12 +128,14 @@ class Audit {
const numCensored = Object.keys(isCensored).length; const numCensored = Object.keys(isCensored).length;
const numMatches = matches.length - 1; // adjust for coinbase tx const numMatches = matches.length - 1; // adjust for coinbase tx
const score = numMatches > 0 ? (numMatches / (numMatches + numCensored)) : 0; const score = numMatches > 0 ? (numMatches / (numMatches + numCensored)) : 0;
const similarity = projectedWeight ? matchedWeight / projectedWeight : 1;
return { return {
censored: Object.keys(isCensored), censored: Object.keys(isCensored),
added, added,
fresh, fresh,
score score,
similarity,
}; };
} }
} }

View File

@ -1,4 +1,4 @@
import { CpfpInfo, TransactionExtended, TransactionStripped } from '../mempool.interfaces'; import { CpfpInfo, MempoolBlockWithTransactions, TransactionExtended, TransactionStripped } from '../mempool.interfaces';
import config from '../config'; import config from '../config';
import { NodeSocket } from '../repositories/NodesSocketsRepository'; import { NodeSocket } from '../repositories/NodesSocketsRepository';
import { isIP } from 'net'; import { isIP } from 'net';
@ -164,6 +164,30 @@ export class Common {
return parents; return parents;
} }
// calculates the ratio of matched transactions to projected transactions by weight
static getSimilarity(projectedBlock: MempoolBlockWithTransactions, transactions: TransactionExtended[]): number {
let matchedWeight = 0;
let projectedWeight = 0;
const inBlock = {};
for (const tx of transactions) {
inBlock[tx.txid] = tx;
}
// look for transactions that were expected in the template, but missing from the mined block
for (const tx of projectedBlock.transactions) {
if (inBlock[tx.txid]) {
matchedWeight += tx.vsize * 4;
}
projectedWeight += tx.vsize * 4;
}
projectedWeight += transactions[0].weight;
matchedWeight += transactions[0].weight;
return projectedWeight ? matchedWeight / projectedWeight : 1;
}
static getSqlInterval(interval: string | null): string | null { static getSqlInterval(interval: string | null): string | null {
switch (interval) { switch (interval) {
case '24h': return '1 DAY'; case '24h': return '1 DAY';

View File

@ -432,7 +432,7 @@ class WebsocketHandler {
} }
if (Common.indexingEnabled() && memPool.isInSync()) { if (Common.indexingEnabled() && memPool.isInSync()) {
const { censored, added, fresh, score } = Audit.auditBlock(transactions, projectedBlocks, auditMempool); const { censored, added, fresh, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool);
const matchRate = Math.round(score * 100 * 100) / 100; const matchRate = Math.round(score * 100 * 100) / 100;
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => { const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => {
@ -464,8 +464,14 @@ class WebsocketHandler {
if (block.extras) { if (block.extras) {
block.extras.matchRate = matchRate; block.extras.matchRate = matchRate;
block.extras.similarity = similarity;
} }
} }
} else if (block.extras) {
const mBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
if (mBlocks?.length && mBlocks[0].transactions) {
block.extras.similarity = Common.getSimilarity(mBlocks[0], transactions);
}
} }
const removed: string[] = []; const removed: string[] = [];

View File

@ -153,6 +153,7 @@ export interface BlockExtension {
feeRange: number[]; // fee rate percentiles feeRange: number[]; // fee rate percentiles
reward: number; reward: number;
matchRate: number | null; matchRate: number | null;
similarity?: number;
pool: { pool: {
id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id` id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id`
name: string; name: string;

View File

@ -2,7 +2,7 @@
<div class="mempool-blocks-container" [class.time-ltr]="timeLtr" *ngIf="(difficultyAdjustments$ | async) as da;"> <div class="mempool-blocks-container" [class.time-ltr]="timeLtr" *ngIf="(difficultyAdjustments$ | async) as da;">
<div class="flashing"> <div class="flashing">
<ng-template ngFor let-projectedBlock [ngForOf]="mempoolBlocks$ | async" let-i="index" [ngForTrackBy]="trackByFn"> <ng-template ngFor let-projectedBlock [ngForOf]="mempoolBlocks$ | async" let-i="index" [ngForTrackBy]="trackByFn">
<div [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink"> <div @blockEntryTrigger [@.disabled]="!animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink">
<a draggable="false" [routerLink]="['/mempool-block/' | relativeUrl, i]" <a draggable="false" [routerLink]="['/mempool-block/' | relativeUrl, i]"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a> class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
<div class="block-body"> <div class="block-body">

View File

@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, Input } from '@angular/core'; import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { Subscription, Observable, fromEvent, merge, of, combineLatest, timer } from 'rxjs'; import { Subscription, Observable, fromEvent, merge, of, combineLatest } from 'rxjs';
import { MempoolBlock } from '../../interfaces/websocket.interface'; import { MempoolBlock } from '../../interfaces/websocket.interface';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@ -9,11 +9,18 @@ import { specialBlocks } from '../../app.constants';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { DifficultyAdjustment } from '../../interfaces/node-api.interface'; import { DifficultyAdjustment } from '../../interfaces/node-api.interface';
import { animate, style, transition, trigger } from '@angular/animations';
@Component({ @Component({
selector: 'app-mempool-blocks', selector: 'app-mempool-blocks',
templateUrl: './mempool-blocks.component.html', templateUrl: './mempool-blocks.component.html',
styleUrls: ['./mempool-blocks.component.scss'], styleUrls: ['./mempool-blocks.component.scss'],
animations: [trigger('blockEntryTrigger', [
transition(':enter', [
style({ transform: 'translateX(-155px)' }),
animate('2s 0s ease', style({ transform: '' })),
]),
])],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class MempoolBlocksComponent implements OnInit, OnDestroy { export class MempoolBlocksComponent implements OnInit, OnDestroy {
@ -32,12 +39,14 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
isLoadingWebsocketSubscription: Subscription; isLoadingWebsocketSubscription: Subscription;
blockSubscription: Subscription; blockSubscription: Subscription;
networkSubscription: Subscription; networkSubscription: Subscription;
chainTipSubscription: Subscription;
network = ''; network = '';
now = new Date().getTime(); now = new Date().getTime();
timeOffset = 0; timeOffset = 0;
showMiningInfo = false; showMiningInfo = false;
timeLtrSubscription: Subscription; timeLtrSubscription: Subscription;
timeLtr: boolean; timeLtr: boolean;
animateEntry: boolean = false;
blockWidth = 125; blockWidth = 125;
blockPadding = 30; blockPadding = 30;
@ -53,6 +62,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
resetTransitionTimeout: number; resetTransitionTimeout: number;
chainTip: number = -1;
blockIndex = 1; blockIndex = 1;
constructor( constructor(
@ -69,6 +79,8 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
} }
ngOnInit() { ngOnInit() {
this.chainTip = this.stateService.latestBlockHeight;
if (['', 'testnet', 'signet'].includes(this.stateService.network)) { if (['', 'testnet', 'signet'].includes(this.stateService.network)) {
this.enabledMiningInfoIfNeeded(this.location.path()); this.enabledMiningInfoIfNeeded(this.location.path());
this.location.onUrlChange((url) => this.enabledMiningInfoIfNeeded(url)); this.location.onUrlChange((url) => this.enabledMiningInfoIfNeeded(url));
@ -153,11 +165,24 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
this.blockSubscription = this.stateService.blocks$ this.blockSubscription = this.stateService.blocks$
.subscribe(([block]) => { .subscribe(([block]) => {
if (block?.extras?.matchRate >= 66 && !this.tabHidden) { if (this.chainTip === -1) {
this.animateEntry = block.height === this.stateService.latestBlockHeight;
} else {
this.animateEntry = block.height > this.chainTip;
}
this.chainTip = this.stateService.latestBlockHeight;
if ((block?.extras?.similarity == null || block?.extras?.similarity > 0.5) && !this.tabHidden) {
this.blockIndex++; this.blockIndex++;
} }
}); });
this.chainTipSubscription = this.stateService.chainTip$.subscribe((height) => {
if (this.chainTip === -1) {
this.chainTip = height;
}
});
this.networkSubscription = this.stateService.networkChanged$ this.networkSubscription = this.stateService.networkChanged$
.subscribe((network) => this.network = network); .subscribe((network) => this.network = network);
@ -193,11 +218,12 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
this.blockSubscription.unsubscribe(); this.blockSubscription.unsubscribe();
this.networkSubscription.unsubscribe(); this.networkSubscription.unsubscribe();
this.timeLtrSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe();
this.chainTipSubscription.unsubscribe();
clearTimeout(this.resetTransitionTimeout); clearTimeout(this.resetTransitionTimeout);
} }
trackByFn(index: number, block: MempoolBlock) { trackByFn(index: number, block: MempoolBlock) {
return block.index; return (block.isStack) ? 'stack' : block.index;
} }
reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] {
@ -214,6 +240,9 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
lastBlock.medianFee = this.median(lastBlock.feeRange); lastBlock.medianFee = this.median(lastBlock.feeRange);
lastBlock.totalFees += block.totalFees; lastBlock.totalFees += block.totalFees;
} }
if (blocks.length) {
blocks[blocks.length - 1].isStack = blocks[blocks.length - 1].blockVSize > this.stateService.blockVSize;
}
return blocks; return blocks;
} }
@ -332,4 +361,4 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
} }
return emptyBlocks; return emptyBlocks;
} }
} }

View File

@ -118,6 +118,7 @@ export interface BlockExtension {
reward?: number; reward?: number;
coinbaseRaw?: string; coinbaseRaw?: string;
matchRate?: number; matchRate?: number;
similarity?: number;
pool?: { pool?: {
id: number; id: number;
name: string; name: string;

View File

@ -43,6 +43,7 @@ export interface MempoolBlock {
totalFees: number; totalFees: number;
feeRange: number[]; feeRange: number[];
index: number; index: number;
isStack?: boolean;
} }
export interface MempoolBlockWithTransactions extends MempoolBlock { export interface MempoolBlockWithTransactions extends MempoolBlock {