mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
Feat: RBF Diff MVP version
This commit is contained in:
parent
034dd895f1
commit
09b93cba75
7 changed files with 533 additions and 629 deletions
|
|
@ -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">
|
||||
‹
|
||||
</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 }}… → {{ edge.newTxidShort }}…
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
[disabled]="selectedEdgeIndex === allEdges.length - 1"
|
||||
(click)="selectEdge(selectedEdgeIndex + 1)"
|
||||
aria-label="Next replacement">
|
||||
›
|
||||
</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 }}…</code> — 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]="'‎' + (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]="'‎' + (tx.weight | wuBytes: 2)"></span>
|
||||
<span *only-vsize [innerHTML]="'‎' + (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]="'‎' + (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">●</span>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #notApplicable>
|
||||
<span class="text-muted null-placeholder" i18n-aria-label="rbf-diff.not-applicable" aria-label="Not applicable">—</span>
|
||||
</ng-template>
|
||||
|
||||
|
||||
|
||||
<ng-template #nodeSpacer>
|
||||
<div class="node-spacer"></div>
|
||||
</ng-template>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 parent→child 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue