mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
Feat: RBF Diff MVP copilot feedback
This commit is contained in:
parent
09b93cba75
commit
f52c48153b
4 changed files with 165 additions and 64 deletions
|
|
@ -28,15 +28,22 @@
|
|||
<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 -->
|
||||
link is disabled rather than navigating away mid-comparison.
|
||||
Dropping routerLink also drops the href, so the role, tab stop
|
||||
and keyboard handlers are restored explicitly. -->
|
||||
<a class="shape-border"
|
||||
[class.rbf]="cell.replacement.tx.rbf"
|
||||
[routerLink]="showDiff ? null : ['/tx/' | relativeUrl, cell.replacement.tx.txid]"
|
||||
[attr.role]="showDiff ? 'button' : null"
|
||||
[attr.tabindex]="showDiff ? 0 : null"
|
||||
(click)="onNodeClick($event, cell.replacement);"
|
||||
(keydown.enter)="onNodeClick($event, cell.replacement);"
|
||||
(keydown.space)="onNodeClick($event, cell.replacement);"
|
||||
(pointerover)="onHover($event, cell.replacement);"
|
||||
(pointerout)="onBlur($event);"
|
||||
>
|
||||
<div class="shape"></div>
|
||||
<span class="visually-hidden" *ngIf="showDiff" i18n="rbf-diff.select-transaction">Select this transaction to compare</span>
|
||||
</a>
|
||||
<span class="fee-rate"><app-fee-rate [fee]="cell.replacement.tx.fee" [weight]="cell.replacement.tx.vsize * 4" [unitStyle]="{ display: 'block', marginTop: '-0.5em'}"></app-fee-rate></span>
|
||||
</div>
|
||||
|
|
@ -49,11 +56,17 @@
|
|||
</ng-container>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="i < timeline.length - 1">
|
||||
<!-- the segment joining two dots: with the diff open it compares that pair directly -->
|
||||
<!-- the segment joining two dots: with the diff open it compares that
|
||||
pair directly, so it needs to be reachable by keyboard too -->
|
||||
<div class="interval-spacer" *ngIf="cell.replacement?.interval != null; else intervalSpacer"
|
||||
[class.diff-selectable]="showDiff && cell.replacement?.replacedBy"
|
||||
(click)="onEdgeClick($event, cell.replacement)">
|
||||
[attr.role]="showDiff && cell.replacement?.replacedBy ? 'button' : null"
|
||||
[attr.tabindex]="showDiff && cell.replacement?.replacedBy ? 0 : null"
|
||||
(click)="onEdgeClick($event, cell.replacement)"
|
||||
(keydown.enter)="onEdgeClick($event, cell.replacement)"
|
||||
(keydown.space)="onEdgeClick($event, cell.replacement)">
|
||||
<div class="track" [class.fullrbf]="cell.fullRbf"></div>
|
||||
<span class="visually-hidden" *ngIf="showDiff && cell.replacement?.replacedBy" i18n="rbf-diff.compare-pair">Compare these two transactions</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,22 @@
|
|||
right: 0;
|
||||
background: linear-gradient(to left, var(--box-bg), var(--box-bg), transparent);
|
||||
}
|
||||
|
||||
// On a phone the wrapper is barely wider than a couple of nodes, so a 2em
|
||||
// band of solid background reads as the line being cut rather than fading.
|
||||
@media (max-width: 575.98px) {
|
||||
&::before, &::after {
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: linear-gradient(to right, var(--box-bg), transparent);
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: linear-gradient(to left, var(--box-bg), transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-out {
|
||||
|
|
@ -261,9 +277,10 @@
|
|||
}
|
||||
|
||||
.diff-hint {
|
||||
width: 100%;
|
||||
width: calc(100% - 2em);
|
||||
margin: 0.5em auto 0;
|
||||
padding: 0 0.5em;
|
||||
text-align: center;
|
||||
margin: 0.5em 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--transparent-fg);
|
||||
|
||||
|
|
@ -306,6 +323,21 @@
|
|||
width: 38%;
|
||||
}
|
||||
|
||||
// styles.scss applies `tr { white-space: nowrap }` to every table, which makes a
|
||||
// long label like "Output (address changed)" spill out of its column and land on
|
||||
// top of the address next to it. Labels wrap inside their own column instead.
|
||||
tbody tr td:first-child {
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
// the tables stack below this width, so the label can afford to be wider
|
||||
col.label-col {
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
.previous-box {
|
||||
border-top: 3px solid var(--red);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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, Subject } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { Transaction, Vout } from '@interfaces/electrs.interface';
|
||||
import { calculateRbfDiff } from '@app/shared/rbf-diff.utils';
|
||||
|
||||
|
|
@ -91,6 +91,9 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy {
|
|||
|
||||
private nodeIndex = new Map<string, RbfTree>();
|
||||
private destroy$ = new Subject<void>();
|
||||
// Comparisons go through one stream so a slower earlier request can never land
|
||||
// on top of a newer selection. A null request cancels whatever is in flight.
|
||||
private diffRequest$ = new Subject<{ oldTxid: string, newTxid: string } | null>();
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
|
|
@ -101,6 +104,27 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy {
|
|||
if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
|
||||
this.dir = 'rtl';
|
||||
}
|
||||
// subscribed here rather than in ngOnInit because ngOnChanges runs first and
|
||||
// can already have queued a comparison
|
||||
this.diffRequest$.pipe(
|
||||
switchMap((request) => request ? forkJoin({
|
||||
oldTx: this.apiService.getRbfCachedTx$(request.oldTxid).pipe(catchError(() => of(null))),
|
||||
newTx: this.apiService.getRbfCachedTx$(request.newTxid).pipe(catchError(() => of(null))),
|
||||
}) : of(null)),
|
||||
takeUntil(this.destroy$),
|
||||
).subscribe((result) => {
|
||||
if (!result) {
|
||||
return; // cancelled by a newer selection
|
||||
}
|
||||
this.diffLoading = false;
|
||||
if (!result.oldTx || !result.newTx) {
|
||||
this.diffError = true;
|
||||
return;
|
||||
}
|
||||
this.selectedOldTx = result.oldTx;
|
||||
this.selectedNewTx = result.newTx;
|
||||
this.diffView = this.buildDiffView(result.oldTx, result.newTx);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
|
@ -375,16 +399,30 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy {
|
|||
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;
|
||||
|
||||
// the viewed transaction is itself a replacement: diff it against what it replaced
|
||||
if (current?.replaces.length) {
|
||||
this.diffOldTxid = current.replaces[0].tx.txid;
|
||||
this.diffNewTxid = current.tx.txid;
|
||||
return;
|
||||
}
|
||||
|
||||
// the viewed transaction was replaced: keep it as the old endpoint. Using the
|
||||
// parent's first child instead would open the diff on a sibling whenever the
|
||||
// replacement swallowed several transactions at once.
|
||||
const parent = current?.replacedBy ? this.nodeIndex.get(current.replacedBy.txid) : null;
|
||||
if (current && parent) {
|
||||
this.diffOldTxid = current.tx.txid;
|
||||
this.diffNewTxid = parent.tx.txid;
|
||||
return;
|
||||
}
|
||||
|
||||
// nothing selected (the replacements list): fall back to the tip of the tree
|
||||
if (this.hasReplacements) {
|
||||
this.diffOldTxid = this.replacements.replaces[0].tx.txid;
|
||||
this.diffNewTxid = this.replacements.tx.txid;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,7 +431,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy {
|
|||
* 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 {
|
||||
onNodeClick(event: Event, node: RbfTree): void {
|
||||
if (!this.showDiff) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -424,7 +462,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy {
|
|||
* 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 {
|
||||
onEdgeClick(event: Event, node: RbfTree | undefined): void {
|
||||
if (!this.showDiff || !node?.replacedBy) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -437,6 +475,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy {
|
|||
}
|
||||
|
||||
private clearDiffResult(): void {
|
||||
this.diffRequest$.next(null); // drop anything still in flight
|
||||
this.diffLoading = false;
|
||||
this.diffError = false;
|
||||
this.selectedOldTx = null;
|
||||
|
|
@ -448,37 +487,10 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy {
|
|||
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.diffView = null;
|
||||
forkJoin({
|
||||
oldTx: this.apiService.getRbfCachedTx$(oldTxid).pipe(
|
||||
catchError(() => of(null))
|
||||
),
|
||||
newTx: this.apiService.getRbfCachedTx$(newTxid).pipe(
|
||||
catchError(() => of(null))
|
||||
),
|
||||
}).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe((result) => {
|
||||
this.diffLoading = false;
|
||||
if (!result.oldTx || !result.newTx) {
|
||||
this.diffError = true;
|
||||
return;
|
||||
}
|
||||
this.selectedOldTx = result.oldTx;
|
||||
this.selectedNewTx = result.newTx;
|
||||
this.diffView = this.buildDiffView(result.oldTx, result.newTx);
|
||||
});
|
||||
this.diffRequest$.next({ oldTxid: this.diffOldTxid, newTxid: this.diffNewTxid });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,6 +28,45 @@ export interface RbfDiff {
|
|||
};
|
||||
}
|
||||
|
||||
interface OutputEntry {
|
||||
out: Vout;
|
||||
index: number;
|
||||
matched: boolean;
|
||||
outputId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A FIFO of candidate outputs sharing a key. Keeping the original order and a
|
||||
* cursor means popping returns the first still-unmatched candidate, exactly as a
|
||||
* linear scan would, without re-walking the whole list for every output.
|
||||
*/
|
||||
interface CandidateQueue {
|
||||
entries: OutputEntry[];
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
function indexBy(entries: OutputEntry[], keyOf: (entry: OutputEntry) => string): Map<string, CandidateQueue> {
|
||||
const index = new Map<string, CandidateQueue>();
|
||||
for (const entry of entries) {
|
||||
const key = keyOf(entry);
|
||||
const queue = index.get(key);
|
||||
if (queue) {
|
||||
queue.entries.push(entry);
|
||||
} else {
|
||||
index.set(key, { entries: [entry], cursor: 0 });
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function takeUnmatched(queue: CandidateQueue | undefined): OutputEntry | undefined {
|
||||
if (!queue) { return undefined; }
|
||||
while (queue.cursor < queue.entries.length && queue.entries[queue.cursor].matched) {
|
||||
queue.cursor++;
|
||||
}
|
||||
return queue.entries[queue.cursor];
|
||||
}
|
||||
|
||||
// Compares structural differences between an original transaction and its RBF replacement
|
||||
|
||||
export function calculateRbfDiff(oldTx: Transaction, newTx: Transaction): RbfDiff {
|
||||
|
|
@ -99,14 +138,17 @@ export function calculateRbfDiff(oldTx: Transaction, newTx: Transaction): RbfDif
|
|||
newAddressCounts.set(addr, (newAddressCounts.get(addr) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Candidate indexes, so each pass looks up its matches instead of rescanning
|
||||
// every remaining output for every output it has to place
|
||||
const byIdAndValue = indexBy(newOutputs, (entry) => `${entry.outputId}|${entry.out.value}`);
|
||||
const byId = indexBy(newOutputs, (entry) => entry.outputId);
|
||||
const byValue = indexBy(newOutputs, (entry) => `${entry.out.value}`);
|
||||
const byIndex = indexBy(newOutputs, (entry) => `${entry.index}`);
|
||||
let anyCursor = 0;
|
||||
|
||||
// Pass 1: match truly unchanged outputs (same address AND value, regardless of position)
|
||||
for (const oldItem of oldOutputs) {
|
||||
const match = newOutputs.find(
|
||||
(newItem) =>
|
||||
!newItem.matched &&
|
||||
oldItem.outputId === newItem.outputId &&
|
||||
oldItem.out.value === newItem.out.value
|
||||
);
|
||||
const match = takeUnmatched(byIdAndValue.get(`${oldItem.outputId}|${oldItem.out.value}`));
|
||||
if (match) {
|
||||
oldItem.matched = true;
|
||||
match.matched = true;
|
||||
|
|
@ -117,11 +159,7 @@ export function calculateRbfDiff(oldTx: Transaction, newTx: Transaction): RbfDif
|
|||
// Pass 2: match remaining outputs by address to detect fee-adjusted or value-modified outputs
|
||||
for (const oldItem of oldOutputs) {
|
||||
if (oldItem.matched) { continue; }
|
||||
const match = newOutputs.find(
|
||||
(newItem) =>
|
||||
!newItem.matched &&
|
||||
oldItem.outputId === newItem.outputId
|
||||
);
|
||||
const match = takeUnmatched(byId.get(oldItem.outputId));
|
||||
if (!match) { continue; }
|
||||
oldItem.matched = true;
|
||||
match.matched = true;
|
||||
|
|
@ -145,18 +183,24 @@ export function calculateRbfDiff(oldTx: Transaction, newTx: Transaction): RbfDif
|
|||
// 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 && newItem.out.value === oldItem.out.value) ??
|
||||
newOutputs.find((newItem) => !newItem.matched && newItem.index === oldItem.index) ??
|
||||
newOutputs.find((newItem) => !newItem.matched);
|
||||
let match =
|
||||
takeUnmatched(byValue.get(`${oldItem.out.value}`)) ??
|
||||
takeUnmatched(byIndex.get(`${oldItem.index}`));
|
||||
if (!match) {
|
||||
while (anyCursor < newOutputs.length && newOutputs[anyCursor].matched) { anyCursor++; }
|
||||
match = newOutputs[anyCursor];
|
||||
}
|
||||
if (!match) { continue; }
|
||||
oldItem.matched = true;
|
||||
match.matched = true;
|
||||
const addressChanged = oldItem.out.scriptpubkey_address !== match.out.scriptpubkey_address;
|
||||
// Compare the stable output id, not the address: two different addressless
|
||||
// scripts (OP_RETURN and friends) both have an undefined address, which would
|
||||
// otherwise report a script replacement as an unchanged destination.
|
||||
const addressChanged = oldItem.outputId !== match.outputId;
|
||||
const valueChanged = oldItem.out.value !== match.out.value;
|
||||
const changeType: 'address' | 'value' | 'both' =
|
||||
addressChanged && valueChanged ? 'both' :
|
||||
addressChanged ? 'address' : 'value';
|
||||
// Anything reaching this pass had no same-id candidate left, so the
|
||||
// destination necessarily differs; only the value may or may not have moved.
|
||||
const changeType: 'address' | 'both' = addressChanged && valueChanged ? 'both' : 'address';
|
||||
modifiedOutputs.push({ old: oldItem.out, new: match.out, index: oldItem.index, changeType });
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue