cache, serve & display more comprehensive RBF info

This commit is contained in:
Mononaut 2022-12-09 10:32:58 -06:00
parent 47d2a6d5c7
commit 0481f57304
No known key found for this signature in database
GPG Key ID: A3F058E41374C04E
9 changed files with 159 additions and 18 deletions

View File

@ -18,6 +18,7 @@ import blocks from '../blocks';
import bitcoinClient from './bitcoin-client';
import difficultyAdjustment from '../difficulty-adjustment';
import transactionRepository from '../../repositories/TransactionRepository';
import rbfCache from '../rbf-cache';
class BitcoinRoutes {
public initRoutes(app: Application) {
@ -31,6 +32,8 @@ 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/cached', this.getCachedTx)
.post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm)
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
try {
@ -589,6 +592,28 @@ class BitcoinRoutes {
}
}
private async getRbfHistory(req: Request, res: Response) {
try {
const result = rbfCache.getReplaces(req.params.txId);
res.json(result?.txids || []);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getCachedTx(req: Request, res: Response) {
try {
const result = rbfCache.getTx(req.params.txId);
if (result) {
res.json(result);
} else {
res.status(404).send('not found');
}
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getTransactionOutspends(req: Request, res: Response) {
try {
const result = await bitcoinApi.$getOutspends(req.params.txId);

View File

@ -51,8 +51,6 @@ export class Common {
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } {
const matches: { [txid: string]: TransactionExtended } = {};
deleted
// The replaced tx must have at least one input with nSequence < maxint-1 (Thats the opt-in)
.filter((tx) => tx.vin.some((vin) => vin.sequence < 0xfffffffe))
.forEach((deletedTx) => {
const foundMatches = added.find((addedTx) => {
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.

View File

@ -210,7 +210,7 @@ class Mempool {
for (const rbfTransaction in rbfTransactions) {
if (this.mempoolCache[rbfTransaction]) {
// Store replaced transactions
rbfCache.add(rbfTransaction, rbfTransactions[rbfTransaction].txid);
rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction].txid);
// Erase the replaced transactions from the local mempool
delete this.mempoolCache[rbfTransaction];
}

View File

@ -1,31 +1,64 @@
import { TransactionExtended } from "../mempool.interfaces";
export interface CachedRbf {
txid: string;
expires: Date;
}
export interface CachedRbfs {
txids: string[];
expires: Date;
}
class RbfCache {
private cache: { [txid: string]: CachedRbf; } = {};
private replacedby: { [txid: string]: CachedRbf; } = {};
private replaces: { [txid: string]: CachedRbfs } = {};
private txs: { [txid: string]: TransactionExtended } = {};
constructor() {
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
}
public add(replacedTxId: string, newTxId: string): void {
this.cache[replacedTxId] = {
expires: new Date(Date.now() + 1000 * 604800), // 1 week
public add(replacedTx: TransactionExtended, newTxId: string): void {
const expiry = new Date(Date.now() + 1000 * 604800); // 1 week
this.replacedby[replacedTx.txid] = {
expires: expiry,
txid: newTxId,
};
this.txs[replacedTx.txid] = replacedTx;
if (!this.replaces[newTxId]) {
this.replaces[newTxId] = {
txids: [],
expires: expiry,
};
}
this.replaces[newTxId].txids.push(replacedTx.txid);
this.replaces[newTxId].expires = expiry;
}
public get(txId: string): CachedRbf | undefined {
return this.cache[txId];
public getReplacedBy(txId: string): CachedRbf | undefined {
return this.replacedby[txId];
}
public getReplaces(txId: string): CachedRbfs | undefined {
return this.replaces[txId];
}
public getTx(txId: string): TransactionExtended | undefined {
return this.txs[txId];
}
private cleanup(): void {
const currentDate = new Date();
for (const c in this.cache) {
if (this.cache[c].expires < currentDate) {
delete this.cache[c];
for (const c in this.replacedby) {
if (this.replacedby[c].expires < currentDate) {
delete this.replacedby[c];
delete this.txs[c];
}
}
for (const c in this.replaces) {
if (this.replaces[c].expires < currentDate) {
delete this.replaces[c];
}
}
}

View File

@ -58,7 +58,7 @@ class WebsocketHandler {
client['track-tx'] = parsedMessage['track-tx'];
// Client is telling the transaction wasn't found
if (parsedMessage['watch-mempool']) {
const rbfCacheTx = rbfCache.get(client['track-tx']);
const rbfCacheTx = rbfCache.getReplacedBy(client['track-tx']);
if (rbfCacheTx) {
response['txReplaced'] = {
txid: rbfCacheTx.txid,

View File

@ -6,7 +6,17 @@
<app-truncate [text]="rbfTransaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, rbfTransaction.txid]"></app-truncate>
</div>
<ng-container *ngIf="!rbfTransaction || rbfTransaction?.size">
<div *ngIf="rbfReplaces?.length" class="alert alert-mempool" role="alert">
<span i18n="transaction.rbf.replaced|RBF replaced">This transaction replaced:</span>
<div class="tx-list">
<a class="alert-link" *ngFor="let replaced of rbfReplaces" [routerLink]="['/tx/' | relativeUrl, replaced]">
<span class="d-inline d-lg-none">{{ replaced | shortenString : 24 }}</span>
<span class="d-none d-lg-inline">{{ replaced }}</span>
</a>
</div>
</div>
<ng-container *ngIf="!rbfTransaction || rbfTransaction?.size || tx">
<h1 i18n="shared.transaction">Transaction</h1>
<span class="tx-link">
@ -25,7 +35,10 @@
<ng-template #confirmationPlural let-i i18n="shared.confirmation-count.plural|Transaction plural confirmation count">{{ i }} confirmations</ng-template>
</button>
</ng-template>
<ng-template [ngIf]="tx && !tx?.status.confirmed">
<ng-template [ngIf]="tx && !tx?.status?.confirmed && replaced">
<button type="button" class="btn btn-sm btn-danger" i18n="transaction.unconfirmed|Transaction unconfirmed state">Replaced</button>
</ng-template>
<ng-template [ngIf]="tx && !tx?.status?.confirmed && !replaced">
<button type="button" class="btn btn-sm btn-danger" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
</ng-template>
</div>
@ -88,7 +101,7 @@
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<ng-template [ngIf]="transactionTime !== 0">
<ng-template [ngIf]="transactionTime !== 0 && !replaced">
<tr *ngIf="transactionTime === -1; else firstSeenTmpl">
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
@ -100,7 +113,7 @@
</tr>
</ng-template>
</ng-template>
<tr>
<tr *ngIf="!replaced">
<td class="td-width" i18n="transaction.eta|Transaction ETA">ETA</td>
<td>
<ng-template [ngIf]="txInBlockIndex === undefined" [ngIfElse]="estimationTmpl">

View File

@ -204,4 +204,10 @@
.txids {
width: 60%;
}
}
.tx-list {
.alert-link {
display: block;
}
}

View File

@ -40,15 +40,21 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
transactionTime = -1;
subscription: Subscription;
fetchCpfpSubscription: Subscription;
fetchRbfSubscription: Subscription;
fetchCachedTxSubscription: Subscription;
txReplacedSubscription: Subscription;
blocksSubscription: Subscription;
queryParamsSubscription: Subscription;
urlFragmentSubscription: Subscription;
fragmentParams: URLSearchParams;
rbfTransaction: undefined | Transaction;
replaced: boolean = false;
rbfReplaces: string[];
cpfpInfo: CpfpInfo | null;
showCpfpDetails = false;
fetchCpfp$ = new Subject<string>();
fetchRbfHistory$ = new Subject<string>();
fetchCachedTx$ = new Subject<string>();
now = new Date().getTime();
timeAvg$: Observable<number>;
liquidUnblinding = new LiquidUnblinding();
@ -159,6 +165,49 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.cpfpInfo = cpfpInfo;
});
this.fetchRbfSubscription = this.fetchRbfHistory$
.pipe(
switchMap((txId) =>
this.apiService
.getRbfHistory$(txId)
),
catchError(() => {
return of([]);
})
).subscribe((replaces) => {
this.rbfReplaces = replaces;
});
this.fetchCachedTxSubscription = this.fetchCachedTx$
.pipe(
switchMap((txId) =>
this.apiService
.getRbfCachedTx$(txId)
),
catchError(() => {
return of(null);
})
).subscribe((tx) => {
if (!tx) {
return;
}
this.tx = tx;
if (tx.fee === undefined) {
this.tx.fee = 0;
}
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
this.isLoadingTx = false;
this.error = undefined;
this.waitingForTransaction = false;
this.graphExpanded = false;
this.setupGraph();
if (!this.tx?.status?.confirmed) {
this.fetchRbfHistory$.next(this.tx.txid);
}
});
this.subscription = this.route.paramMap
.pipe(
switchMap((params: ParamMap) => {
@ -272,6 +321,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
} else {
this.fetchCpfp$.next(this.tx.txid);
}
this.fetchRbfHistory$.next(this.tx.txid);
}
setTimeout(() => { this.applyFragment(); }, 0);
},
@ -303,6 +353,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
}
this.rbfTransaction = rbfTransaction;
this.cacheService.setTxCache([this.rbfTransaction]);
this.replaced = true;
if (rbfTransaction && !this.tx) {
this.fetchCachedTx$.next(this.txId);
}
});
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
@ -368,8 +422,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.waitingForTransaction = false;
this.isLoadingTx = true;
this.rbfTransaction = undefined;
this.replaced = false;
this.transactionTime = -1;
this.cpfpInfo = null;
this.rbfReplaces = [];
this.showCpfpDetails = false;
document.body.scrollTo(0, 0);
this.leaveTransaction();
@ -435,6 +491,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy() {
this.subscription.unsubscribe();
this.fetchCpfpSubscription.unsubscribe();
this.fetchRbfSubscription.unsubscribe();
this.fetchCachedTxSubscription.unsubscribe();
this.txReplacedSubscription.unsubscribe();
this.blocksSubscription.unsubscribe();
this.queryParamsSubscription.unsubscribe();

View File

@ -5,7 +5,7 @@ import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITrans
import { Observable } from 'rxjs';
import { StateService } from './state.service';
import { WebsocketResponse } from '../interfaces/websocket.interface';
import { Outspend } from '../interfaces/electrs.interface';
import { Outspend, Transaction } from '../interfaces/electrs.interface';
@Injectable({
providedIn: 'root'
@ -119,6 +119,14 @@ export class ApiService {
return this.httpClient.get<AddressInformation>(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address);
}
getRbfHistory$(txid: string): Observable<string[]> {
return this.httpClient.get<string[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/replaces');
}
getRbfCachedTx$(txid: string): Observable<Transaction> {
return this.httpClient.get<Transaction>(this.apiBaseUrl + this.apiBasePath + '/api/v1/tx/' + txid + '/cached');
}
listLiquidPegsMonth$(): Observable<LiquidPegs[]> {
return this.httpClient.get<LiquidPegs[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month');
}