diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts
index 298ae3715..b8c86bbe2 100644
--- a/backend/src/api/bitcoin/bitcoin.routes.ts
+++ b/backend/src/api/bitcoin/bitcoin.routes.ts
@@ -32,7 +32,7 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', this.getBackendInfo)
.get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData)
.get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress)
- .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/replaces', this.getRbfHistory)
+ .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/rbf', this.getRbfHistory)
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx)
.post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm)
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
@@ -642,8 +642,12 @@ class BitcoinRoutes {
private async getRbfHistory(req: Request, res: Response) {
try {
- const result = rbfCache.getReplaces(req.params.txId);
- res.json(result || []);
+ const replacements = rbfCache.getRbfChain(req.params.txId) || [];
+ const replaces = rbfCache.getReplaces(req.params.txId) || null;
+ res.json({
+ replacements,
+ replaces
+ });
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts
index 0d593f1a3..8b2728c1c 100644
--- a/backend/src/api/mempool.ts
+++ b/backend/src/api/mempool.ts
@@ -269,7 +269,7 @@ class Mempool {
for (const rbfTransaction in rbfTransactions) {
if (this.mempoolCache[rbfTransaction]) {
// Store replaced transactions
- rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction].txid);
+ rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction]);
// Erase the replaced transactions from the local mempool
delete this.mempoolCache[rbfTransaction];
}
diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts
index 410239e73..8557ec232 100644
--- a/backend/src/api/rbf-cache.ts
+++ b/backend/src/api/rbf-cache.ts
@@ -1,8 +1,15 @@
-import { TransactionExtended } from "../mempool.interfaces";
+import { TransactionExtended, TransactionStripped } from "../mempool.interfaces";
+import { Common } from "./common";
+
+interface RbfTransaction extends TransactionStripped {
+ rbf?: boolean;
+}
class RbfCache {
private replacedBy: { [txid: string]: string; } = {};
private replaces: { [txid: string]: string[] } = {};
+ private rbfChains: { [root: string]: { tx: TransactionStripped, time: number, mined?: boolean }[] } = {}; // sequences of consecutive replacements
+ private chainMap: { [txid: string]: string } = {}; // map of txids to sequence ids
private txs: { [txid: string]: TransactionExtended } = {};
private expiring: { [txid: string]: Date } = {};
@@ -10,13 +17,34 @@ class RbfCache {
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
}
- public add(replacedTx: TransactionExtended, newTxId: string): void {
- this.replacedBy[replacedTx.txid] = newTxId;
- this.txs[replacedTx.txid] = replacedTx;
- if (!this.replaces[newTxId]) {
- this.replaces[newTxId] = [];
+ public add(replacedTxExtended: TransactionExtended, newTxExtended: TransactionExtended): void {
+ const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
+ replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
+ const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
+ newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
+
+ this.replacedBy[replacedTx.txid] = newTx.txid;
+ this.txs[replacedTx.txid] = replacedTxExtended;
+ if (!this.replaces[newTx.txid]) {
+ this.replaces[newTx.txid] = [];
+ }
+ this.replaces[newTx.txid].push(replacedTx.txid);
+
+ // maintain rbf chains
+ if (this.chainMap[replacedTx.txid]) {
+ // add to an existing chain
+ const chainRoot = this.chainMap[replacedTx.txid];
+ this.rbfChains[chainRoot].push({ tx: newTx, time: newTxExtended.firstSeen || Date.now() });
+ this.chainMap[newTx.txid] = chainRoot;
+ } else {
+ // start a new chain
+ this.rbfChains[replacedTx.txid] = [
+ { tx: replacedTx, time: replacedTxExtended.firstSeen || Date.now() },
+ { tx: newTx, time: newTxExtended.firstSeen || Date.now() },
+ ];
+ this.chainMap[replacedTx.txid] = replacedTx.txid;
+ this.chainMap[newTx.txid] = replacedTx.txid;
}
- this.replaces[newTxId].push(replacedTx.txid);
}
public getReplacedBy(txId: string): string | undefined {
@@ -31,6 +59,10 @@ class RbfCache {
return this.txs[txId];
}
+ public getRbfChain(txId: string): { tx: TransactionStripped, time: number }[] {
+ return this.rbfChains[this.chainMap[txId]] || [];
+ }
+
// flag a transaction as removed from the mempool
public evict(txid): void {
this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours
@@ -48,14 +80,20 @@ class RbfCache {
// remove a transaction & all previous versions from the cache
private remove(txid): void {
- // don't remove a transaction while a newer version remains in the mempool
- if (this.replaces[txid] && !this.replacedBy[txid]) {
+ // don't remove a transaction if a newer version remains in the mempool
+ if (!this.replacedBy[txid]) {
const replaces = this.replaces[txid];
delete this.replaces[txid];
+ delete this.chainMap[txid];
+ delete this.txs[txid];
+ delete this.expiring[txid];
for (const tx of replaces) {
// recursively remove prior versions from the cache
delete this.replacedBy[tx];
- delete this.txs[tx];
+ // if this is the root of a chain, remove that too
+ if (this.chainMap[tx] === tx) {
+ delete this.rbfChains[tx];
+ }
this.remove(tx);
}
}
diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html
new file mode 100644
index 000000000..a7b96f000
--- /dev/null
+++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ replacement.tx.fee / (replacement.tx.vsize) | feeRounding }} sat/vB
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss
new file mode 100644
index 000000000..af0e75744
--- /dev/null
+++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss
@@ -0,0 +1,137 @@
+.rbf-timeline {
+ position: relative;
+ width: 100%;
+ padding: 1em 0;
+
+ &::after, &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 2em;
+ z-index: 2;
+ }
+
+ &::before {
+ left: 0;
+ background: linear-gradient(to right, #24273e, #24273e, transparent);
+ }
+
+ &::after {
+ right: 0;
+ background: linear-gradient(to left, #24273e, #24273e, transparent);
+ }
+
+ .timeline {
+ position: relative;
+ width: calc(100% - 2em);
+ margin: auto;
+ overflow-x: auto;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+
+ .intervals, .nodes {
+ min-width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: flex-start;
+ text-align: center;
+
+ .node, .node-spacer {
+ width: 4em;
+ min-width: 4em;
+ flex-grow: 1;
+ }
+
+ .interval, .interval-spacer {
+ width: 8em;
+ min-width: 4em;
+ max-width: 8em;
+ }
+
+ .interval-time {
+ font-size: 12px;
+ }
+ }
+
+ .node, .interval-spacer {
+ position: relative;
+ .track {
+ position: absolute;
+ height: 10px;
+ left: -5px;
+ right: -5px;
+ top: 0;
+ transform: translateY(-50%);
+ background: #105fb0;
+ border-radius: 5px;
+ }
+ &:first-child {
+ .track {
+ left: 50%;
+ }
+ }
+ &:last-child {
+ .track {
+ right: 50%;
+ }
+ }
+ }
+
+ .nodes {
+ position: relative;
+ margin-top: 1em;
+ .node {
+ .shape-border {
+ display: block;
+ margin: auto;
+ height: calc(1em + 8px);
+ width: calc(1em + 8px);
+ margin-bottom: -8px;
+ transform: translateY(-50%);
+ border-radius: 10%;
+ cursor: pointer;
+ padding: 4px;
+ background: transparent;
+ transition: background-color 300ms, padding 300ms;
+
+ .shape {
+ width: 100%;
+ height: 100%;
+ border-radius: 10%;
+ background: white;
+ transition: background-color 300ms;
+ }
+
+ &.rbf, &.rbf .shape {
+ border-radius: 50%;
+ }
+ }
+
+ .symbol::ng-deep {
+ display: block;
+ margin-top: -0.5em;
+ }
+
+ &.selected {
+ .shape-border {
+ background: #9339f4;
+ }
+ }
+
+ .shape-border:hover {
+ padding: 0px;
+ .shape {
+ background: #1bd8f4;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts
new file mode 100644
index 000000000..b053158b4
--- /dev/null
+++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts
@@ -0,0 +1,36 @@
+import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID } from '@angular/core';
+import { Router } from '@angular/router';
+import { RbfInfo } from '../../interfaces/node-api.interface';
+import { StateService } from '../../services/state.service';
+import { ApiService } from '../../services/api.service';
+
+@Component({
+ selector: 'app-rbf-timeline',
+ templateUrl: './rbf-timeline.component.html',
+ styleUrls: ['./rbf-timeline.component.scss'],
+})
+export class RbfTimelineComponent implements OnInit, OnChanges {
+ @Input() replacements: RbfInfo[];
+ @Input() txid: string;
+
+ dir: 'rtl' | 'ltr' = 'ltr';
+
+ constructor(
+ private router: Router,
+ private stateService: StateService,
+ private apiService: ApiService,
+ @Inject(LOCALE_ID) private locale: string,
+ ) {
+ if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
+ this.dir = 'rtl';
+ }
+ }
+
+ ngOnInit(): void {
+
+ }
+
+ ngOnChanges(): void {
+
+ }
+}
diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html
index 04d13b07a..1710b538f 100644
--- a/frontend/src/app/components/transaction/transaction.component.html
+++ b/frontend/src/app/components/transaction/transaction.component.html
@@ -197,6 +197,15 @@
+
+
+
Replacements
+
+
+
+
+
+
Flow
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts
index 5f23633bf..d89bf4e2b 100644
--- a/frontend/src/app/components/transaction/transaction.component.ts
+++ b/frontend/src/app/components/transaction/transaction.component.ts
@@ -19,7 +19,7 @@ import { WebsocketService } from '../../services/websocket.service';
import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service';
-import { BlockExtended, CpfpInfo } from '../../interfaces/node-api.interface';
+import { BlockExtended, CpfpInfo, RbfInfo } from '../../interfaces/node-api.interface';
import { LiquidUnblinding } from './liquid-ublinding';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { Price, PriceService } from '../../services/price.service';
@@ -53,6 +53,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
rbfTransaction: undefined | Transaction;
replaced: boolean = false;
rbfReplaces: string[];
+ rbfInfo: RbfInfo[];
cpfpInfo: CpfpInfo | null;
showCpfpDetails = false;
fetchCpfp$ = new Subject
();
@@ -183,10 +184,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
.getRbfHistory$(txId)
),
catchError(() => {
- return of([]);
+ return of(null);
})
- ).subscribe((replaces) => {
- this.rbfReplaces = replaces;
+ ).subscribe((rbfResponse) => {
+ this.rbfInfo = rbfResponse?.replacements || [];
+ this.rbfReplaces = rbfResponse?.replaces || null;
});
this.fetchCachedTxSubscription = this.fetchCachedTx$
@@ -460,6 +462,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.replaced = false;
this.transactionTime = -1;
this.cpfpInfo = null;
+ this.rbfInfo = [];
this.rbfReplaces = [];
this.showCpfpDetails = false;
document.body.scrollTo(0, 0);
diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts
index 46654a3b7..442fb73ce 100644
--- a/frontend/src/app/interfaces/node-api.interface.ts
+++ b/frontend/src/app/interfaces/node-api.interface.ts
@@ -26,6 +26,11 @@ export interface CpfpInfo {
bestDescendant?: BestDescendant | null;
}
+export interface RbfInfo {
+ tx: RbfTransaction,
+ time: number
+}
+
export interface DifficultyAdjustment {
progressPercent: number;
difficultyChange: number;
@@ -146,6 +151,10 @@ export interface TransactionStripped {
status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
}
+interface RbfTransaction extends TransactionStripped {
+ rbf?: boolean;
+}
+
export interface RewardStats {
startBlock: number;
endBlock: number;
diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts
index 2b4e460a2..fda957a8a 100644
--- a/frontend/src/app/services/api.service.ts
+++ b/frontend/src/app/services/api.service.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
- PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights } from '../interfaces/node-api.interface';
+ PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfInfo } from '../interfaces/node-api.interface';
import { Observable } from 'rxjs';
import { StateService } from './state.service';
import { WebsocketResponse } from '../interfaces/websocket.interface';
@@ -124,8 +124,8 @@ export class ApiService {
return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address);
}
- getRbfHistory$(txid: string): Observable {
- return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/replaces');
+ getRbfHistory$(txid: string): Observable<{ replacements: RbfInfo[], replaces: string[] }> {
+ return this.httpClient.get<{ replacements: RbfInfo[], replaces: string[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/rbf');
}
getRbfCachedTx$(txid: string): Observable {
diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts
index e276f79f8..7313ec8e3 100644
--- a/frontend/src/app/shared/shared.module.ts
+++ b/frontend/src/app/shared/shared.module.ts
@@ -61,6 +61,7 @@ import { DifficultyComponent } from '../components/difficulty/difficulty.compone
import { DifficultyTooltipComponent } from '../components/difficulty/difficulty-tooltip.component';
import { DifficultyMiningComponent } from '../components/difficulty-mining/difficulty-mining.component';
import { TermsOfServiceComponent } from '../components/terms-of-service/terms-of-service.component';
+import { RbfTimelineComponent } from '../components/rbf-timeline/rbf-timeline.component';
import { TxBowtieGraphComponent } from '../components/tx-bowtie-graph/tx-bowtie-graph.component';
import { TxBowtieGraphTooltipComponent } from '../components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component';
import { PrivacyPolicyComponent } from '../components/privacy-policy/privacy-policy.component';
@@ -138,6 +139,7 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.
DifficultyComponent,
DifficultyMiningComponent,
DifficultyTooltipComponent,
+ RbfTimelineComponent,
TxBowtieGraphComponent,
TxBowtieGraphTooltipComponent,
TermsOfServiceComponent,
@@ -242,6 +244,7 @@ import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.
DifficultyComponent,
DifficultyMiningComponent,
DifficultyTooltipComponent,
+ RbfTimelineComponent,
TxBowtieGraphComponent,
TxBowtieGraphTooltipComponent,
TermsOfServiceComponent,