Feat: RBF Diff MVP version

This commit is contained in:
jramos0 2026-08-06 02:45:00 -06:00
parent 034dd895f1
commit 09b93cba75
7 changed files with 533 additions and 629 deletions

View file

@ -23,12 +23,16 @@
[class.first-node]="cell.first"
[class.diff-old]="highlightedOldTxid === cell.replacement.tx.txid"
[class.diff-new]="highlightedNewTxid === cell.replacement.tx.txid"
[class.diff-pending]="pendingAnchorTxid === cell.replacement.tx.txid"
>
<div class="track left" [class.fullrbf]="cell.replacement?.tx?.fullRbf"></div>
<div class="track right" [class.fullrbf]="cell.fullRbf"></div>
<!-- with the diff open the node picks the pair to compare, so the
link is disabled rather than navigating away mid-comparison -->
<a class="shape-border"
[class.rbf]="cell.replacement.tx.rbf"
[routerLink]="['/tx/' | relativeUrl, cell.replacement.tx.txid]"
[routerLink]="showDiff ? null : ['/tx/' | relativeUrl, cell.replacement.tx.txid]"
(click)="onNodeClick($event, cell.replacement);"
(pointerover)="onHover($event, cell.replacement);"
(pointerout)="onBlur($event);"
>
@ -45,7 +49,10 @@
</ng-container>
</ng-template>
<ng-container *ngIf="i < timeline.length - 1">
<div class="interval-spacer" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
<!-- the segment joining two dots: with the diff open it compares that pair directly -->
<div class="interval-spacer" *ngIf="cell.replacement?.interval != null; else intervalSpacer"
[class.diff-selectable]="showDiff && cell.replacement?.replacedBy"
(click)="onEdgeClick($event, cell.replacement)">
<div class="track" [class.fullrbf]="cell.fullRbf"></div>
</div>
</ng-container>
@ -64,37 +71,10 @@
</ng-template>
</div>
<!-- RBF Structural Diff Button -->
<div class="diff-button-wrapper" *ngIf="allEdges.length > 0">
<button class="btn btn-sm btn-secondary diff-toggle" (click)="toggleStructuralDiff();">
<i class="bi bi-code-slash" aria-hidden="true"></i>
<span *ngIf="!showDiff" i18n="rbf-diff-show">Show structural diff</span>
<span *ngIf="showDiff" i18n="rbf-diff-hide">Hide structural diff</span>
</button>
</div>
<!-- Edge Navigator (only when multiple edges exist) -->
<div class="edge-navigator" *ngIf="showDiff && allEdges.length > 1">
<button class="btn btn-sm btn-outline-secondary"
[disabled]="selectedEdgeIndex === 0"
(click)="selectEdge(selectedEdgeIndex - 1)"
aria-label="Previous replacement">
&#8249;
</button>
<select class="form-select form-select-sm edge-select"
[ngModel]="selectedEdgeIndex"
(ngModelChange)="selectEdge($event)">
<option *ngFor="let edge of allEdges; let i = index" [ngValue]="i">
{{ edge.oldTxidShort }}&hellip; &#8594; {{ edge.newTxidShort }}&hellip;
</option>
</select>
<button class="btn btn-sm btn-outline-secondary"
[disabled]="selectedEdgeIndex === allEdges.length - 1"
(click)="selectEdge(selectedEdgeIndex + 1)"
aria-label="Next replacement">
&#8250;
</button>
<span class="edge-counter">{{ selectedEdgeIndex + 1 }} / {{ allEdges.length }}</span>
<!-- How to drive the comparison, shown only while the diff is open -->
<div class="diff-hint" *ngIf="showDiff">
<ng-container *ngIf="!awaitingSecondPick" i18n="rbf-diff.pick-hint">Click the line between two transactions to compare them, or click any two transactions to compare those.</ng-container>
<ng-container *ngIf="awaitingSecondPick" i18n="rbf-diff.pick-second">Comparing from <code>{{ pendingAnchorShort }}&hellip;</code> &mdash; now click the transaction to compare it with.</ng-container>
</div>
<!-- RBF Diff Loading -->
@ -110,11 +90,12 @@
</div>
<!-- RBF Structural Diff Content -->
<div class="rbf-diff-section" *ngIf="showDiff && !diffLoading && comparisonData && selectedOldTx && selectedNewTx">
<div class="rbf-diff-section" *ngIf="showDiff && !diffLoading && diffView && selectedOldTx && selectedNewTx">
<div class="row">
<!-- Left Column: Previous Transaction -->
<div class="col-sm">
<table class="table table-borderless table-striped previous-box">
<colgroup><col class="label-col"><col></colgroup>
<tbody>
<tr class="diff-head previous">
<td colspan="2">
@ -126,7 +107,7 @@
</td>
</tr>
<ng-container *ngTemplateOutlet="previousDetails"></ng-container>
<ng-container *ngTemplateOutlet="diffRows; context: { $implicit: 'previous', tx: selectedOldTx, diff: diffView }"></ng-container>
</tbody>
</table>
</div>
@ -134,6 +115,7 @@
<!-- Right Column: Current Transaction -->
<div class="col-sm">
<table class="table table-borderless table-striped current-box">
<colgroup><col class="label-col"><col></colgroup>
<tbody>
<tr class="diff-head current">
<td colspan="2">
@ -145,146 +127,149 @@
</td>
</tr>
<ng-container *ngTemplateOutlet="currentDetails"></ng-container>
<ng-container *ngTemplateOutlet="diffRows; context: { $implicit: 'current', tx: selectedNewTx, diff: diffView }"></ng-container>
</tbody>
</table>
</div>
</div>
</div>
<!-- Previous Transaction Table -->
<ng-template #previousDetails>
<!-- Metadata Section -->
<ng-container *ngIf="comparisonData.metadata.hasChanges">
<ng-container *ngFor="let row of comparisonData.metadata.rows">
<tr>
<td>
<ng-container [ngSwitch]="row.i18nKey">
<span *ngSwitchCase="'transaction.version'" i18n="transaction.version">Version</span>
<span *ngSwitchCase="'transaction.locktime'" i18n="transaction.locktime">Locktime</span>
<span *ngSwitchDefault>{{ row.label }}</span>
</ng-container>
</td>
<td>{{ row.previous }}</td>
</tr>
</ng-container>
<!--
One row set, rendered once per side so the two tables always line up.
The side's transaction comes in through the outlet context, and every label
is static so Angular extracts it for translation at build time.
-->
<ng-template #diffRows let-side let-tx="tx" let-diff="diff">
<tr *ngIf="diff.versionChanged">
<td i18n="transaction.version">Version</td>
<td>
{{ tx.version }}
<ng-container *ngIf="side === 'current'">
<ng-container *ngTemplateOutlet="changedDot"></ng-container>
</ng-container>
</td>
</tr>
<!-- Metrics Section -->
<ng-container *ngIf="comparisonData.metrics.hasChanges">
<ng-container *ngFor="let row of comparisonData.metrics.rows">
<tr>
<td>
<ng-container [ngSwitch]="row.i18nKey">
<span *ngSwitchCase="'transaction.fee'" i18n="transaction.fee">Fee</span>
<span *ngSwitchCase="'transaction.weight'" i18n="transaction.weight">Weight</span>
<span *ngSwitchCase="'transaction.vsize'" i18n="transaction.vsize">Virtual size</span>
<span *ngSwitchDefault>{{ row.label }}</span>
</ng-container>
</td>
<td>
<ng-container *ngIf="row.isAmount">
<app-amount [satoshis]="asNumber(row.previous)" [noFiat]="true" [forceSats]="row.i18nKey === 'transaction.fee'"></app-amount>
</ng-container>
<ng-container *ngIf="!row.isAmount">
<span [innerHTML]="'&lrm;' + (row.previous | number) + ' ' + row.unit"></span>
</ng-container>
</td>
</tr>
</ng-container>
<tr *ngIf="diff.locktimeChanged">
<td i18n="transaction.locktime">Locktime</td>
<td>
{{ tx.locktime }}
<ng-container *ngIf="side === 'current'">
<ng-container *ngTemplateOutlet="changedDot"></ng-container>
</ng-container>
</td>
</tr>
<!-- Inputs Section -->
<ng-container *ngIf="comparisonData.inputs.hasChanges">
<ng-container *ngFor="let row of comparisonData.inputs.rows">
<tr>
<td [attr.i18n]="row.i18nKey">{{ row.label }}</td>
<td>
<span *ngIf="row.previous !== null">{{ row.previous }}</span>
<span *ngIf="row.previous === null" class="text-muted null-placeholder" aria-label="Not applicable"></span>
</td>
</tr>
</ng-container>
<tr *ngIf="diff.feeChanged">
<td i18n="transaction.fee|Transaction fee">Fee</td>
<td>
<app-amount [satoshis]="tx.fee" [noFiat]="true" [forceSats]="true"></app-amount>
<span *ngIf="side === 'current' && diff.feePercent !== null" class="difference"
[class.positive]="!diff.feeIncreased" [class.negative]="diff.feeIncreased">
{{ diff.feePercent > 0 ? '+' : '' }}{{ diff.feePercent | number: '1.0-1' }}%
</span>
</td>
</tr>
<tr *ngIf="diff.feeChanged || diff.weightChanged">
<td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
<td>
<app-fee-rate [fee]="tx.fee" [weight]="tx.weight"></app-fee-rate>
</td>
</tr>
<tr *ngIf="diff.weightChanged">
<td>
<span *only-weight i18n="transaction.weight|Transaction Weight">Weight</span>
<span *only-vsize i18n="transaction.vsize|Transaction Virtual Size">Virtual size</span>
</td>
<td>
<span *only-weight [innerHTML]="'&lrm;' + (tx.weight | wuBytes: 2)"></span>
<span *only-vsize [innerHTML]="'&lrm;' + (tx.weight / 4 | vbytes: 2)"></span>
<span *ngIf="side === 'current' && diff.weightPercent !== null" class="difference"
[class.positive]="!diff.weightIncreased" [class.negative]="diff.weightIncreased">
{{ diff.weightPercent > 0 ? '+' : '' }}{{ diff.weightPercent | number: '1.0-1' }}%
</span>
</td>
</tr>
<tr *ngIf="diff.inputCountChanged">
<td i18n="rbf-diff.inputs-count">Total inputs</td>
<td>
{{ tx.vin.length }}
<ng-container *ngIf="side === 'current'">
<ng-container *ngTemplateOutlet="changedDot"></ng-container>
</ng-container>
</td>
</tr>
<!-- Outputs Section -->
<ng-container *ngIf="comparisonData.outputs.hasChanges">
<ng-container *ngFor="let row of comparisonData.outputs.rows">
<tr>
<td [attr.i18n]="row.i18nKey">{{ row.label }}</td>
<td>
<span *ngIf="row.previous !== null">{{ row.previous }}</span>
<span *ngIf="row.previous === null" class="text-muted null-placeholder" aria-label="Not applicable"></span>
</td>
</tr>
</ng-container>
<tr *ngIf="diff.addedInputs > 0">
<td i18n="rbf-diff.inputs-added">Added inputs</td>
<td>
<ng-container *ngIf="side === 'current'; else notApplicable">{{ diff.addedInputs }}</ng-container>
</td>
</tr>
<tr *ngIf="diff.removedInputs > 0">
<td i18n="rbf-diff.inputs-removed">Removed inputs</td>
<td>
<ng-container *ngIf="side === 'previous'; else notApplicable">{{ diff.removedInputs }}</ng-container>
</td>
</tr>
<tr *ngIf="diff.outputCountChanged">
<td i18n="rbf-diff.outputs-count">Total outputs</td>
<td>
{{ tx.vout.length }}
<ng-container *ngIf="side === 'current'">
<ng-container *ngTemplateOutlet="changedDot"></ng-container>
</ng-container>
</td>
</tr>
<!--
The point of the whole feature: which output went where. A replaced
destination is styled as a warning; a value reduced by exactly the fee
bump is not flagged as a change at all.
-->
<tr *ngFor="let row of diff.outputRows" [class.address-changed]="row.addressChanged">
<td>
<span *ngIf="row.addressChanged" i18n="rbf-diff.output-address-changed">Output (address changed)</span>
<span *ngIf="!row.addressChanged && row.feeAdjusted" i18n="rbf-diff.output-fee-adjusted">Output (fee adjusted)</span>
<span *ngIf="!row.addressChanged && !row.feeAdjusted" i18n="transaction.output">Output</span>
</td>
<td>
<ng-container *ngIf="(side === 'previous' ? row.previous : row.current) as out; else notApplicable">
<!-- app-truncate only ellipsizes when its flex container is bounded, so
this stays block-level rather than inline -->
<span class="output-address" *ngIf="out.scriptpubkey_address; else scriptType">
<app-truncate [text]="out.scriptpubkey_address" [lastChars]="8"
[link]="['/address/' | relativeUrl, out.scriptpubkey_address]"></app-truncate>
</span>
<ng-template #scriptType>
<span class="output-script-type">{{ out.scriptpubkey_type | scriptpubkeyType }}</span>
</ng-template>
<span class="output-value">
<app-amount [satoshis]="out.value" [noFiat]="true"></app-amount>
<ng-container *ngIf="side === 'current' && row.addressChanged">
<ng-container *ngTemplateOutlet="changedDot"></ng-container>
</ng-container>
</span>
</ng-container>
</td>
</tr>
</ng-template>
<!-- Current Transaction Table -->
<ng-template #currentDetails>
<!-- Metadata Section -->
<ng-container *ngIf="comparisonData.metadata.hasChanges">
<ng-container *ngFor="let row of comparisonData.metadata.rows">
<tr>
<td [attr.i18n]="row.i18nKey">{{ row.label }}</td>
<td>
{{ row.current }}
<span class="changed-indicator" role="img" aria-label="Changed value"></span>
</td>
</tr>
</ng-container>
</ng-container>
<!-- Metrics Section -->
<ng-container *ngIf="comparisonData.metrics.hasChanges">
<ng-container *ngFor="let row of comparisonData.metrics.rows">
<tr>
<td [attr.i18n]="row.i18nKey">{{ row.label }}</td>
<td>
<ng-container *ngIf="row.isAmount">
<app-amount [satoshis]="asNumber(row.current)" [noFiat]="true" [forceSats]="row.i18nKey === 'transaction.fee'"></app-amount>
</ng-container>
<ng-container *ngIf="!row.isAmount">
<span [innerHTML]="'&lrm;' + (row.current | number) + ' ' + row.unit"></span>
</ng-container>
<span *ngIf="row.percentage !== null" class="difference" [class.positive]="row.changeType === 'positive'" [class.negative]="row.changeType === 'negative'">
{{ row.percentage > 0 ? '+' : '' }}{{ row.percentage | number: '1.0-1' }}%
</span>
</td>
</tr>
</ng-container>
</ng-container>
<!-- Inputs Section -->
<ng-container *ngIf="comparisonData.inputs.hasChanges">
<ng-container *ngFor="let row of comparisonData.inputs.rows">
<tr>
<td [attr.i18n]="row.i18nKey">{{ row.label }}</td>
<td>
<span *ngIf="row.current !== null">{{ row.current }}</span>
<span *ngIf="row.current === null" class="text-muted null-placeholder" aria-label="Not applicable"></span>
</td>
</tr>
</ng-container>
</ng-container>
<!-- Outputs Section -->
<ng-container *ngIf="comparisonData.outputs.hasChanges">
<ng-container *ngFor="let row of comparisonData.outputs.rows">
<tr>
<td [attr.i18n]="row.i18nKey">{{ row.label }}</td>
<td>
<span *ngIf="row.current !== null">{{ row.current }}</span>
<span *ngIf="row.current === null" class="text-muted null-placeholder" aria-label="Not applicable"></span>
</td>
</tr>
</ng-container>
</ng-container>
<ng-template #changedDot>
<span class="changed-indicator" role="img" i18n-aria-label="rbf-diff.changed-value" aria-label="Changed value">&#9679;</span>
</ng-template>
<ng-template #notApplicable>
<span class="text-muted null-placeholder" i18n-aria-label="rbf-diff.not-applicable" aria-label="Not applicable">&mdash;</span>
</ng-template>
<ng-template #nodeSpacer>
<div class="node-spacer"></div>
</ng-template>

View file

@ -15,10 +15,10 @@
display: none;
}
/* Fade just for the timeline (doesn't affect the diff) */
&::before,
&::after {
// scoped to the timeline so the gradients don't overlay the diff tables below
&::after, &::before {
content: '';
display: block;
position: absolute;
top: 0;
bottom: 0;
@ -38,22 +38,27 @@
}
}
/* Align ALL (toggle, diff button, diff) to the same width as timeline-wrapper */
.toggle-wrapper,
.diff-button-wrapper,
.rbf-diff-section {
width: calc(100% - 2em);
margin-left: auto;
margin-right: auto;
.fade-out {
position: relative;
&::before {
content: '';
position: absolute;
width: 100%;
height: 70px;
top: -70px;
background: linear-gradient(to bottom, var(--fade-out-box-bg-start), var(--fade-out-box-bg-end));
z-index: 1;
}
}
.toggle-wrapper {
width: 100%;
text-align: center;
margin: 1.25em 0 0;
}
.intervals,
.nodes {
.intervals, .nodes {
min-width: 100%;
display: flex;
flex-direction: row;
@ -61,16 +66,13 @@
align-items: flex-start;
text-align: center;
.node,
.node-spacer,
.connector {
.node, .node-spacer, .connector {
width: 6em;
min-width: 6em;
flex-grow: 1;
}
.interval,
.interval-spacer {
.interval, .interval-spacer {
width: 8em;
min-width: 5em;
max-width: 8em;
@ -92,10 +94,17 @@
}
}
.node,
.interval-spacer {
.node, .interval-spacer {
position: relative;
&.diff-selectable {
cursor: pointer;
&:hover .track {
background: var(--info);
}
}
.track {
position: absolute;
height: 10px;
@ -109,7 +118,6 @@
&.left {
right: 50%;
}
&.right {
left: 50%;
}
@ -118,13 +126,11 @@
background: var(--info);
}
}
&.first-node {
.track.left {
display: none;
}
}
&:last-child {
.track.right {
display: none;
@ -135,7 +141,6 @@
.nodes {
position: relative;
margin-top: 1em;
.node {
.shape-border {
display: block;
@ -158,8 +163,7 @@
transition: background-color 300ms, border 300ms;
}
&.rbf,
&.rbf .shape {
&.rbf, &.rbf .shape {
border-radius: 50%;
}
}
@ -178,26 +182,11 @@
.shape-border:hover {
padding: 0px;
.shape {
background: var(--info);
}
}
&.diff-old {
.shape-border {
background: var(--red);
opacity: 0.85;
}
}
&.diff-new {
.shape-border {
background: var(--green);
opacity: 0.85;
}
}
&.selected.mined {
.shape-border {
background: var(--success);
@ -210,33 +199,51 @@
&:hover {
padding: 4px;
.shape {
border-width: 1px;
border-color: var(--info);
border-color: var(--info)
}
}
}
}
// the pair currently being diffed, matching the Previous / Current
// column accents in .rbf-diff-section
&.diff-old .shape-border,
&.diff-old.selected .shape-border,
&.diff-old.mined .shape-border {
background: var(--red);
}
&.diff-new .shape-border,
&.diff-new.selected .shape-border,
&.diff-new.mined .shape-border {
background: var(--green);
}
// first half of a two-click comparison, waiting for the other end
&.diff-pending .shape-border,
&.diff-pending.selected .shape-border,
&.diff-pending.mined .shape-border {
background: var(--tertiary);
outline: 2px dashed var(--fg);
}
}
.connector {
position: relative;
height: 10px;
.corner,
.pipe {
.corner, .pipe {
position: absolute;
left: -10px;
width: 20px;
height: 108px;
bottom: 50%;
border-right: solid 10px var(--primary);
&.fullrbf {
border-right: solid 10px var(--info);
}
&.last-pipe {
height: 150px;
bottom: -42px;
@ -246,7 +253,6 @@
.corner {
border-bottom: solid 10px var(--primary);
border-bottom-right-radius: 10px;
&.fullrbf {
border-bottom: solid 10px var(--info);
}
@ -254,48 +260,50 @@
}
}
.diff-button-wrapper {
.diff-hint {
width: 100%;
text-align: center;
margin: 0.75em 0 0;
}
margin: 0.5em 0 0;
font-size: 0.85rem;
color: var(--transparent-fg);
.edge-navigator {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin: 0.75rem auto 0;
width: calc(100% - 2em);
.edge-select {
width: auto;
min-width: 220px;
text-align: center;
font-family: monospace;
font-size: 0.85rem;
}
.edge-counter {
font-size: 0.85rem;
color: var(--fg-alt);
white-space: nowrap;
code {
background: transparent;
padding: 0;
color: var(--link-color);
}
}
.diff-loading {
margin: 1rem auto 0;
width: calc(100% - 2em);
width: 100%;
padding: 1rem 0;
}
}
.rbf-diff-section {
margin-top: 1rem;
width: calc(100% - 2em);
margin: 1rem auto 0;
padding-top: 0.5rem;
border-top: 1px solid var(--box-border);
border-top: 1px solid var(--border-subtle);
// Bootstrap's .col-sm doesn't shrink below its content, and a full address is
// wide enough to push both columns past the container and wrap them onto
// separate lines. Bounding the cells keeps the two tables side by side.
.col-sm {
min-width: 0;
}
table {
margin-bottom: 0;
// fixed layout so a long address can never widen the table; the colgroup in
// the template sets the column split, since the colspan'd header row would
// otherwise be the one deciding it
table-layout: fixed;
width: 100%;
}
col.label-col {
width: 38%;
}
.previous-box {
@ -318,6 +326,7 @@
align-items: baseline;
gap: 0.35rem;
white-space: nowrap;
overflow: hidden;
code {
margin: 0;
@ -350,4 +359,32 @@
.difference.negative {
color: var(--red);
}
// a replaced destination is a warning, not a neutral delta. The label carries
// the colour so the address itself still reads as an ordinary link.
tr.address-changed td:first-child {
color: var(--red);
}
.address-changed .changed-indicator {
color: var(--red);
}
.output-address {
display: block;
max-width: 100%;
overflow: hidden;
font-size: 0.9rem;
}
.output-script-type {
font-size: 0.9rem;
color: var(--transparent-fg);
}
.output-value {
display: block;
font-size: 0.85rem;
color: var(--transparent-fg);
}
}

View file

@ -1,12 +1,12 @@
import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID, HostListener } from '@angular/core';
import { Component, Input, OnInit, OnChanges, OnDestroy, Inject, LOCALE_ID, HostListener } from '@angular/core';
import { Router } from '@angular/router';
import { RbfTree, RbfTransaction } from '@interfaces/node-api.interface';
import { StateService } from '@app/services/state.service';
import { ApiService } from '@app/services/api.service';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Transaction } from '@interfaces/electrs.interface';
import { calculateRbfDiff, RbfDiff } from '@app/shared/rbf-diff.utils';
import { forkJoin, of, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { Transaction, Vout } from '@interfaces/electrs.interface';
import { calculateRbfDiff } from '@app/shared/rbf-diff.utils';
type Connector = 'pipe' | 'corner';
@ -18,49 +18,36 @@ interface TimelineCell {
}
/**
* Represents a single replacement edge in the RBF tree (parent replaced child).
* Used to let users navigate and diff any adjacent pair in a branching tree.
* One output of the replacement, aligned across the Previous and Current tables.
* Every row renders on both sides so the two tables stay in step; the side that
* doesn't exist gets a placeholder.
*/
interface RbfEdge {
oldTxid: string; // the replaced (older) transaction
newTxid: string; // the replacement (newer) transaction
oldTxidShort: string; // first 8 chars for display
newTxidShort: string; // first 8 chars for display
interface OutputDiffRow {
previous: Vout | null;
current: Vout | null;
addressChanged: boolean;
// value reduced by exactly the fee bump — not a real change to the output
feeAdjusted: boolean;
}
/**
* Represents a single row in the comparison table
* Each row shows: label, previous value, current value, and optional highlighting
* Everything the diff tables render. A field is only flagged when it actually
* changed, so unchanged rows never reach the template.
*/
interface ComparisonRow {
label: string; // e.g., "Version", "Locktime", "Fee"
previous: string | number | null; // Value from old transaction
current: string | number | null; // Value from new transaction
changed: boolean; // Whether the value changed
changeType?: 'positive' | 'negative' | 'neutral'; // For styling
i18nKey?: string; // For internationalization
percentage?: number | null; // Pre-calculated percentage change
isAmount?: boolean; // Whether this row displays an amount (for app-amount component)
unit?: string; // Unit label for metrics (e.g., 'WU', 'vB')
}
/**
* Represents a comparison section (metadata, metrics, inputs, outputs)
*/
interface ComparisonSection {
title: string;
rows: ComparisonRow[];
hasChanges: boolean; // Whether any row in this section changed
}
/**
* Complete comparison table data structure
*/
interface ComparisonTableData {
metadata: ComparisonSection; // Version, Locktime
metrics: ComparisonSection; // Fee, Weight, vSize
inputs: ComparisonSection; // Input changes summary
outputs: ComparisonSection; // Output changes summary
interface RbfDiffView {
versionChanged: boolean;
locktimeChanged: boolean;
feeChanged: boolean;
feePercent: number | null;
feeIncreased: boolean;
weightChanged: boolean;
weightPercent: number | null;
weightIncreased: boolean;
inputCountChanged: boolean;
addedInputs: number;
removedInputs: number;
outputCountChanged: boolean;
outputRows: OutputDiffRow[];
}
function isTimelineCell(val: RbfTree | TimelineCell): boolean {
@ -73,10 +60,13 @@ function isTimelineCell(val: RbfTree | TimelineCell): boolean {
styleUrls: ['./rbf-timeline.component.scss'],
standalone: false,
})
export class RbfTimelineComponent implements OnInit, OnChanges {
export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy {
@Input() replacements: RbfTree;
@Input() txid: string;
@Input() rowLimit: number = 5; // If explicitly set to 0, all timelines rows will be displayed by default
// Owned by the parent so the toggle can live beside the section heading, the
// same way the transaction flow diagram is driven from transaction.component
@Input() showDiff: boolean = false;
rows: TimelineCell[][] = [];
timelineExpanded: boolean = this.rowLimit === 0;
@ -88,15 +78,19 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
// RBF Diff state
selectedOldTx: Transaction | null = null;
selectedNewTx: Transaction | null = null;
rbfDiff: RbfDiff | null = null;
showDiff: boolean = false;
diffLoading: boolean = false;
diffError: boolean = false;
comparisonData: ComparisonTableData | null = null;
diffView: RbfDiffView | null = null;
// Edge navigation for branching RBF trees
allEdges: RbfEdge[] = [];
selectedEdgeIndex: number = 0;
// The pair currently being diffed. Any two nodes in the tree can be compared,
// not just adjacent ones, so both ends are tracked independently.
diffOldTxid: string | null = null;
diffNewTxid: string | null = null;
// First half of a two-click selection, waiting for the user to pick the other end
pendingAnchorTxid: string | null = null;
private nodeIndex = new Map<string, RbfTree>();
private destroy$ = new Subject<void>();
constructor(
private router: Router,
@ -111,17 +105,35 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
ngOnInit(): void {
this.rows = this.buildTimelines(this.replacements);
this.updateEdges();
this.indexNodes();
this.resolveDefaultPair(true);
}
ngOnChanges(changes): void {
this.rows = this.buildTimelines(this.replacements);
this.updateEdges();
if (changes.txid && !changes.txid.firstChange && changes.txid.previousValue !== changes.txid.currentValue) {
this.indexNodes();
const txidChanged = changes.txid && changes.txid.previousValue !== changes.txid.currentValue;
const previousPair = `${this.diffOldTxid}:${this.diffNewTxid}`;
this.resolveDefaultPair(txidChanged);
if (changes.showDiff) {
this.pendingAnchorTxid = null;
this.clearDiffResult();
if (this.showDiff) {
this.loadDiff();
}
} else if (this.showDiff && `${this.diffOldTxid}:${this.diffNewTxid}` !== previousPair) {
this.loadDiff();
}
if (txidChanged && !changes.txid.firstChange) {
setTimeout(() => { this.scrollToSelected(); });
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// converts a tree of RBF events into a format that can be more easily rendered in HTML
buildTimelines(tree: RbfTree): TimelineCell[][] {
if (!tree) {return [];}
@ -283,15 +295,171 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
this.hoverInfo = null;
}
// Builds a txid -> node lookup so the template can resolve the diff pair in
// constant time instead of walking the tree on every change detection cycle
private indexNodes(): void {
this.nodeIndex.clear();
const walk = (node: RbfTree): void => {
if (!node) { return; }
this.nodeIndex.set(node.tx.txid, node);
node.replaces.forEach(walk);
};
walk(this.replacements);
}
// The tree always has at least one replacement edge if the root replaced anything
private get hasReplacements(): boolean {
return !!this.replacements?.replaces?.length;
}
// While a pick is in progress the previous pair's colours are dropped: leaving
// a stale red and green on the timeline alongside the dashed anchor reads as if
// three transactions were selected at once.
get highlightedOldTxid(): string | null {
return this.showDiff && !this.pendingAnchorTxid ? this.diffOldTxid : null;
}
get highlightedNewTxid(): string | null {
return this.showDiff && !this.pendingAnchorTxid ? this.diffNewTxid : null;
}
/**
* Fetches full transaction details for two transactions and computes their structural diff
* @param oldTxid - The original transaction ID
* @param newTxid - The replacement transaction ID
* True when the user has picked one end of a comparison and we're waiting for
* the other. The tables are hidden in this state so the hint is unambiguous.
*/
compareTxs(oldTxid: string, newTxid: string): void {
get awaitingSecondPick(): boolean {
return this.showDiff && this.pendingAnchorTxid !== null;
}
get pendingAnchorShort(): string {
return this.pendingAnchorTxid ? this.pendingAnchorTxid.substring(0, 8) : '';
}
// Walks forward in time from `from` via replacedBy, looking for `target`
private replacedByChainReaches(from: RbfTree, targetTxid: string): boolean {
const seen = new Set<string>();
let cursor: RbfTree | undefined = from;
while (cursor?.replacedBy && !seen.has(cursor.tx.txid)) {
seen.add(cursor.tx.txid);
if (cursor.replacedBy.txid === targetTxid) {
return true;
}
cursor = this.nodeIndex.get(cursor.replacedBy.txid);
}
return false;
}
/**
* Works out which of two freely-picked nodes is the older one. Ancestry in the
* replacement chain decides it; nodes on separate branches have no such
* relationship, so those fall back to the timestamps.
*/
private orderPair(a: RbfTree, b: RbfTree): [string, string] {
if (this.replacedByChainReaches(a, b.tx.txid)) {
return [a.tx.txid, b.tx.txid];
}
if (this.replacedByChainReaches(b, a.tx.txid)) {
return [b.tx.txid, a.tx.txid];
}
return (a.time ?? 0) <= (b.time ?? 0)
? [a.tx.txid, b.tx.txid]
: [b.tx.txid, a.tx.txid];
}
private resolveDefaultPair(force: boolean = false): void {
// a websocket refresh shouldn't throw away whichever pair the user picked
if (!force && this.diffOldTxid && this.diffNewTxid
&& this.nodeIndex.has(this.diffOldTxid) && this.nodeIndex.has(this.diffNewTxid)) {
return;
}
this.pendingAnchorTxid = null;
this.diffOldTxid = null;
this.diffNewTxid = null;
// default to the transaction being viewed against what it replaced, falling
// back to the tip of the tree when nothing is selected (the replacements list)
const current = (this.txid ? this.nodeIndex.get(this.txid) : null) ?? null;
const node = current?.replaces.length
? current
: (current?.replacedBy ? this.nodeIndex.get(current.replacedBy.txid) : null)
?? (this.hasReplacements ? this.replacements : null);
if (node?.replaces.length) {
this.diffOldTxid = node.replaces[0].tx.txid;
this.diffNewTxid = node.tx.txid;
}
}
/**
* With the diff open, nodes stop being links and become comparison endpoints.
* The first click anchors one end, the second picks the other which is what
* makes it possible to compare transactions that aren't next to each other.
*/
onNodeClick(event: MouseEvent, node: RbfTree): void {
if (!this.showDiff) {
return;
}
// routerLink is already null while the diff is open, so this only has to
// suppress the anchor's own default behaviour
event.preventDefault();
if (!this.pendingAnchorTxid) {
this.pendingAnchorTxid = node.tx.txid;
this.clearDiffResult();
return;
}
if (this.pendingAnchorTxid === node.tx.txid) {
// clicking the anchor again cancels the selection
this.pendingAnchorTxid = null;
this.loadDiff();
return;
}
const anchor = this.nodeIndex.get(this.pendingAnchorTxid);
this.pendingAnchorTxid = null;
if (!anchor) {
return;
}
[this.diffOldTxid, this.diffNewTxid] = this.orderPair(anchor, node);
this.loadDiff();
}
/**
* Clicking the line joining two dots compares exactly those two the quick
* path for the common case of two consecutive replacements.
*/
onEdgeClick(event: MouseEvent, node: RbfTree | undefined): void {
if (!this.showDiff || !node?.replacedBy) {
return;
}
event.preventDefault();
event.stopPropagation();
this.pendingAnchorTxid = null;
this.diffOldTxid = node.tx.txid;
this.diffNewTxid = node.replacedBy.txid;
this.loadDiff();
}
private clearDiffResult(): void {
this.diffLoading = false;
this.diffError = false;
this.selectedOldTx = null;
this.selectedNewTx = null;
this.diffView = null;
}
private loadDiff(): void {
if (!this.diffOldTxid || !this.diffNewTxid) {
return;
}
this.compareTxs(this.diffOldTxid, this.diffNewTxid);
}
/**
* Fetches both transactions from the RBF cache and computes their structural diff.
* @param oldTxid - the replaced transaction
* @param newTxid - the replacement
*/
private compareTxs(oldTxid: string, newTxid: string): void {
this.diffError = false;
this.diffLoading = true;
this.comparisonData = null;
this.diffView = null;
forkJoin({
oldTx: this.apiService.getRbfCachedTx$(oldTxid).pipe(
catchError(() => of(null))
@ -299,7 +467,9 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
newTx: this.apiService.getRbfCachedTx$(newTxid).pipe(
catchError(() => of(null))
),
}).subscribe((result) => {
}).pipe(
takeUntil(this.destroy$)
).subscribe((result) => {
this.diffLoading = false;
if (!result.oldTx || !result.newTx) {
this.diffError = true;
@ -307,345 +477,47 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
}
this.selectedOldTx = result.oldTx;
this.selectedNewTx = result.newTx;
this.rbfDiff = calculateRbfDiff(result.oldTx, result.newTx);
this.prepareComparisonTable();
this.showDiff = true;
this.diffView = this.buildDiffView(result.oldTx, result.newTx);
});
}
closeDiff(): void {
this.showDiff = false;
this.diffLoading = false;
this.diffError = false;
this.selectedOldTx = null;
this.selectedNewTx = null;
this.rbfDiff = null;
this.comparisonData = null;
}
/**
* Prepares the RBF diff data into a block-audit-style comparison table structure
* FIXED: Only includes changed rows, calculates deltas in TypeScript, correct units
* Reduces the structural diff to just what the tables render. Rows for
* unchanged fields are left out entirely rather than rendered as noise.
*/
prepareComparisonTable(): void {
if (!this.rbfDiff || !this.selectedOldTx || !this.selectedNewTx) {
return;
}
private buildDiffView(oldTx: Transaction, newTx: Transaction): RbfDiffView {
const diff = calculateRbfDiff(oldTx, newTx);
// ========================================
// METADATA SECTION - Only add if changed
// ========================================
const metadataRows: ComparisonRow[] = [];
// Most important first: a replaced destination is the reason to look at all
const outputRows: OutputDiffRow[] = [
...diff.outputs.modified
.filter(m => m.changeType !== 'value')
.map(m => ({ previous: m.old, current: m.new, addressChanged: true, feeAdjusted: false })),
...diff.outputs.modified
.filter(m => m.changeType === 'value')
.map(m => ({ previous: m.old, current: m.new, addressChanged: false, feeAdjusted: false })),
...diff.outputs.removed
.map(out => ({ previous: out, current: null, addressChanged: false, feeAdjusted: false })),
...diff.outputs.added
.map(out => ({ previous: null, current: out, addressChanged: false, feeAdjusted: false })),
...diff.outputs.feeAdjusted
.map(adj => ({ previous: adj.old, current: adj.new, addressChanged: false, feeAdjusted: true })),
];
if (this.rbfDiff.transaction.versionChanged) {
metadataRows.push({
label: 'Version',
i18nKey: 'transaction.version',
previous: this.rbfDiff.transaction.oldVersion,
current: this.rbfDiff.transaction.newVersion,
changed: true,
changeType: 'neutral'
});
}
if (this.rbfDiff.transaction.locktimeChanged) {
metadataRows.push({
label: 'Locktime',
i18nKey: 'transaction.locktime',
previous: this.rbfDiff.transaction.oldLocktime,
current: this.rbfDiff.transaction.newLocktime,
changed: true,
changeType: 'neutral'
});
}
// ========================================
// METRICS SECTION - Only metrics that changed
// ========================================
const metricsRows: ComparisonRow[] = [];
// Fee (always changed if in this section)
if (this.rbfDiff.metrics.feeDelta !== null) {
const oldFee = this.selectedOldTx.fee;
const newFee = this.selectedNewTx.fee;
const feePercentage = oldFee > 0 ? ((newFee - oldFee) / oldFee) * 100 : null;
metricsRows.push({
label: 'Fee',
i18nKey: 'transaction.fee',
previous: oldFee,
current: newFee,
changed: true,
changeType: this.rbfDiff.metrics.feeDelta > 0 ? 'negative' : 'positive',
percentage: feePercentage,
isAmount: true
});
}
// Weight (with percentage)
if (this.rbfDiff.metrics.weightDelta !== null) {
const oldWeight = this.selectedOldTx.weight;
const newWeight = this.selectedNewTx.weight;
const weightPercentage = oldWeight > 0 ? ((newWeight - oldWeight) / oldWeight) * 100 : null;
metricsRows.push({
label: 'Weight',
i18nKey: 'transaction.weight',
previous: oldWeight,
current: newWeight,
changed: true,
changeType: this.rbfDiff.metrics.weightDelta > 0 ? 'negative' : 'positive',
percentage: weightPercentage,
isAmount: false,
unit: 'WU'
});
}
// Virtual size (with percentage) - FIXED: unit is vB
if (this.rbfDiff.metrics.vsizeDelta !== null) {
const oldVsize = this.selectedOldTx.size;
const newVsize = this.selectedNewTx.size;
const vsizePercentage = oldVsize > 0 ? ((newVsize - oldVsize) / oldVsize) * 100 : null;
metricsRows.push({
label: 'Virtual size',
i18nKey: 'transaction.vsize',
previous: oldVsize,
current: newVsize,
changed: true,
changeType: this.rbfDiff.metrics.vsizeDelta > 0 ? 'negative' : 'positive',
percentage: vsizePercentage,
isAmount: false,
unit: 'vB'
});
}
// ========================================
// INPUTS SECTION - FIXED: Delta formatting in TypeScript
// ========================================
const inputsRows: ComparisonRow[] = [];
const oldInputCount = this.selectedOldTx.vin.length;
const newInputCount = this.selectedNewTx.vin.length;
const inputDelta = newInputCount - oldInputCount;
// Total Inputs - ONLY add if count changed, with formatted delta
if (inputDelta !== 0) {
const sign = inputDelta > 0 ? '+' : '';
const formattedCurrent = `${newInputCount} (${sign}${inputDelta})`;
inputsRows.push({
label: 'Total Inputs',
i18nKey: 'transaction.inputs-count',
previous: oldInputCount,
current: formattedCurrent,
changed: true,
changeType: 'neutral'
});
}
// Added Inputs
if (this.rbfDiff.inputs.added.length > 0) {
inputsRows.push({
label: 'Added Inputs',
i18nKey: 'rbf-diff.inputs-added',
previous: null,
current: this.rbfDiff.inputs.added.length,
changed: true,
changeType: 'positive'
});
}
// Removed Inputs
if (this.rbfDiff.inputs.removed.length > 0) {
inputsRows.push({
label: 'Removed Inputs',
i18nKey: 'rbf-diff.inputs-removed',
previous: this.rbfDiff.inputs.removed.length,
current: null,
changed: true,
changeType: 'negative'
});
}
// ========================================
// OUTPUTS SECTION - FIXED: Delta formatting in TypeScript
// ========================================
const outputsRows: ComparisonRow[] = [];
const oldOutputCount = this.selectedOldTx.vout.length;
const newOutputCount = this.selectedNewTx.vout.length;
const outputDelta = newOutputCount - oldOutputCount;
// Total Outputs - ONLY add if count changed, with formatted delta
if (outputDelta !== 0) {
const sign = outputDelta > 0 ? '+' : '';
const formattedCurrent = `${newOutputCount} (${sign}${outputDelta})`;
outputsRows.push({
label: 'Total Outputs',
i18nKey: 'transaction.outputs-count',
previous: oldOutputCount,
current: formattedCurrent,
changed: true,
changeType: 'neutral'
});
}
// Added Outputs
if (this.rbfDiff.outputs.added.length > 0) {
outputsRows.push({
label: 'Added Outputs',
i18nKey: 'rbf-diff.outputs-added',
previous: null,
current: this.rbfDiff.outputs.added.length,
changed: true,
changeType: 'positive'
});
}
// Removed Outputs
if (this.rbfDiff.outputs.removed.length > 0) {
outputsRows.push({
label: 'Removed Outputs',
i18nKey: 'rbf-diff.outputs-removed',
previous: this.rbfDiff.outputs.removed.length,
current: null,
changed: true,
changeType: 'negative'
});
}
// Modified Outputs
if (this.rbfDiff.outputs.modified.length > 0) {
outputsRows.push({
label: 'Modified Outputs',
i18nKey: 'rbf-diff.outputs-modified',
previous: null,
current: this.rbfDiff.outputs.modified.length,
changed: true,
changeType: 'neutral'
});
}
// Fee-Adjusted Outputs
if (this.rbfDiff.outputs.feeAdjusted.length > 0) {
outputsRows.push({
label: 'Fee-Adjusted Outputs',
i18nKey: 'rbf-diff.outputs-fee-adjusted',
previous: null,
current: this.rbfDiff.outputs.feeAdjusted.length,
changed: true,
changeType: 'neutral'
});
}
// ========================================
// CONSTRUCT FINAL DATA - hasChanges based on row count
// ========================================
this.comparisonData = {
metadata: {
title: 'Transaction Metadata',
rows: metadataRows,
hasChanges: metadataRows.length > 0
},
metrics: {
title: 'Metrics',
rows: metricsRows,
hasChanges: metricsRows.length > 0
},
inputs: {
title: 'Inputs',
rows: inputsRows,
hasChanges: inputsRows.length > 0
},
outputs: {
title: 'Outputs',
rows: outputsRows,
hasChanges: outputsRows.length > 0
}
return {
versionChanged: diff.transaction.versionChanged,
locktimeChanged: diff.transaction.locktimeChanged,
feeChanged: diff.metrics.feeDelta !== null,
feePercent: oldTx.fee > 0 ? ((newTx.fee - oldTx.fee) / oldTx.fee) * 100 : null,
feeIncreased: newTx.fee > oldTx.fee,
weightChanged: diff.metrics.weightDelta !== null,
weightPercent: oldTx.weight > 0 ? ((newTx.weight - oldTx.weight) / oldTx.weight) * 100 : null,
weightIncreased: newTx.weight > oldTx.weight,
inputCountChanged: oldTx.vin.length !== newTx.vin.length,
addedInputs: diff.inputs.added.length,
removedInputs: diff.inputs.removed.length,
outputCountChanged: oldTx.vout.length !== newTx.vout.length,
outputRows,
};
}
/**
* Type-safe helper to convert comparison row values to numbers
* Used for app-amount component which requires strict number type
*/
asNumber(value: unknown): number {
if (typeof value === 'number' && !Number.isNaN(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
throw new TypeError(`asNumber: Expected a numeric value or numeric string, received ${typeof value}`);
}
/**
* Extracts all parentchild replacement edges from the RBF tree using BFS.
* BFS ordering means users see all direct replacements at each level before
* going deeper into branches more intuitive for dropdown navigation.
*/
private extractEdges(tree: RbfTree): RbfEdge[] {
if (!tree) { return []; }
const edges: RbfEdge[] = [];
const queue: RbfTree[] = [tree];
while (queue.length > 0) {
const node = queue.shift();
for (const child of node.replaces) {
edges.push({
oldTxid: child.tx.txid,
newTxid: node.tx.txid,
oldTxidShort: child.tx.txid.substring(0, 8),
newTxidShort: node.tx.txid.substring(0, 8),
});
queue.push(child);
}
}
return edges;
}
/**
* Rebuilds the edge list and sets the default selected edge
* to the one where the current tx is the replacement (newTxid).
*/
private updateEdges(): void {
this.allEdges = this.extractEdges(this.replacements);
const currentEdgeIndex = this.allEdges.findIndex(e => e.newTxid === this.txid);
this.selectedEdgeIndex = currentEdgeIndex >= 0 ? currentEdgeIndex : 0;
}
/**
* Selects an edge by index and loads the diff for that pair.
*/
selectEdge(index: number): void {
if (index < 0 || index >= this.allEdges.length) { return; }
this.selectedEdgeIndex = index;
const edge = this.allEdges[index];
this.compareTxs(edge.oldTxid, edge.newTxid);
}
get highlightedOldTxid(): string | null {
return this.showDiff && this.allEdges.length > 0
? this.allEdges[this.selectedEdgeIndex]?.oldTxid
: null;
}
get highlightedNewTxid(): string | null {
return this.showDiff && this.allEdges.length > 0
? this.allEdges[this.selectedEdgeIndex]?.newTxid
: null;
}
// Toggles the structural diff visibility
toggleStructuralDiff(): void {
if (this.showDiff) {
this.closeDiff();
} else if (this.allEdges.length > 0) {
this.selectEdge(this.selectedEdgeIndex);
}
}
}

View file

@ -136,8 +136,14 @@
<div class="title float-start">
<h2 id="rbf" i18n="transaction.rbf-history|RBF Timeline">RBF Timeline</h2>
</div>
<button *ngIf="rbfInfo.replaces?.length" type="button" class="btn btn-outline-info rbf-diff-toggle btn-sm float-end" (click)="showRbfDiff = !showRbfDiff">
<span *ngIf="!showRbfDiff" i18n="rbf-diff.show">Show structural diff</span>
<span *ngIf="showRbfDiff" i18n="rbf-diff.hide">Hide structural diff</span>
</button>
<div class="clearfix"></div>
<app-rbf-timeline [txid]="txId" [replacements]="rbfInfo"></app-rbf-timeline>
<app-rbf-timeline [txid]="txId" [replacements]="rbfInfo" [showDiff]="showRbfDiff"></app-rbf-timeline>
<br>
</ng-container>

View file

@ -191,7 +191,7 @@
}
}
.details-button, .flow-toggle, .accelerator-toggle {
.details-button, .flow-toggle, .accelerator-toggle, .rbf-diff-toggle {
margin-top: -5px;
margin-left: 10px;
@media (min-width: 768px){

View file

@ -148,6 +148,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
hideFlow: boolean = this.stateService.hideFlow.value;
overrideFlowPreference: boolean = null;
flowEnabled: boolean;
showRbfDiff: boolean = false;
isDetailsOpen: boolean = false;
tooltipPosition: { x: number, y: number };
isMobile: boolean;

View file

@ -24,7 +24,7 @@ export interface RbfDiff {
metrics: {
feeDelta: number | null; // null if unchanged
weightDelta: number | null; // null if unchanged
vsizeDelta: number | null; // null if unchanged
// vsize is weight / 4, so it is covered by weightDelta and not tracked separately
};
}
@ -140,10 +140,15 @@ export function calculateRbfDiff(oldTx: Transaction, newTx: Transaction): RbfDif
}
}
// Pass 3: match any still-unmatched outputs as best-effort (address changed)
// Pass 3: match any still-unmatched outputs as best-effort (address changed).
// Prefer a leftover of equal value — a pure destination swap keeps the amount —
// then one at the same index, before falling back to document order.
for (const oldItem of oldOutputs) {
if (oldItem.matched) { continue; }
const match = newOutputs.find((newItem) => !newItem.matched);
const match =
newOutputs.find((newItem) => !newItem.matched && newItem.out.value === oldItem.out.value) ??
newOutputs.find((newItem) => !newItem.matched && newItem.index === oldItem.index) ??
newOutputs.find((newItem) => !newItem.matched);
if (!match) { continue; }
oldItem.matched = true;
match.matched = true;
@ -165,7 +170,6 @@ export function calculateRbfDiff(oldTx: Transaction, newTx: Transaction): RbfDif
// METRICS (only include if changed)
const weightDelta = newTx.weight - oldTx.weight;
const vsizeDelta = newTx.size - oldTx.size;
return {
transaction: {
@ -191,7 +195,6 @@ export function calculateRbfDiff(oldTx: Transaction, newTx: Transaction): RbfDif
metrics: {
feeDelta: feeDelta !== 0 ? feeDelta : null,
weightDelta: weightDelta !== 0 ? weightDelta : null,
vsizeDelta: vsizeDelta !== 0 ? vsizeDelta : null,
},
};
}