Merge pull request #2824 from mempool/mononaut/more-rbf-info
cache, serve & display more comprehensive RBF info
This commit is contained in:
		
						commit
						59eb271782
					
				@ -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 || []);
 | 
			
		||||
    } 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);
 | 
			
		||||
 | 
			
		||||
@ -60,8 +60,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 (That’s 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.
 | 
			
		||||
@ -70,7 +68,7 @@ export class Common {
 | 
			
		||||
            && addedTx.feePerVsize > deletedTx.feePerVsize
 | 
			
		||||
            // Spends one or more of the same inputs
 | 
			
		||||
            && deletedTx.vin.some((deletedVin) =>
 | 
			
		||||
              addedTx.vin.some((vin) => vin.txid === deletedVin.txid));
 | 
			
		||||
              addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
 | 
			
		||||
            });
 | 
			
		||||
        if (foundMatches) {
 | 
			
		||||
          matches[deletedTx.txid] = foundMatches;
 | 
			
		||||
 | 
			
		||||
@ -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];
 | 
			
		||||
      }
 | 
			
		||||
@ -236,6 +236,7 @@ class Mempool {
 | 
			
		||||
      const lazyDeleteAt = this.mempoolCache[tx].deleteAfter;
 | 
			
		||||
      if (lazyDeleteAt && lazyDeleteAt < now) {
 | 
			
		||||
        delete this.mempoolCache[tx];
 | 
			
		||||
        rbfCache.evict(tx);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -1,31 +1,62 @@
 | 
			
		||||
export interface CachedRbf {
 | 
			
		||||
  txid: string;
 | 
			
		||||
  expires: Date;
 | 
			
		||||
}
 | 
			
		||||
import { TransactionExtended } from "../mempool.interfaces";
 | 
			
		||||
 | 
			
		||||
class RbfCache {
 | 
			
		||||
  private cache: { [txid: string]: CachedRbf; } = {};
 | 
			
		||||
  private replacedBy: { [txid: string]: string; } = {};
 | 
			
		||||
  private replaces: { [txid: string]: string[] } = {};
 | 
			
		||||
  private txs: { [txid: string]: TransactionExtended } = {};
 | 
			
		||||
  private expiring: { [txid: string]: Date } = {};
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
      txid: newTxId,
 | 
			
		||||
    };
 | 
			
		||||
  public add(replacedTx: TransactionExtended, newTxId: string): void {
 | 
			
		||||
    this.replacedBy[replacedTx.txid] = newTxId;
 | 
			
		||||
    this.txs[replacedTx.txid] = replacedTx;
 | 
			
		||||
    if (!this.replaces[newTxId]) {
 | 
			
		||||
      this.replaces[newTxId] = [];
 | 
			
		||||
    }
 | 
			
		||||
    this.replaces[newTxId].push(replacedTx.txid);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public get(txId: string): CachedRbf | undefined {
 | 
			
		||||
    return this.cache[txId];
 | 
			
		||||
  public getReplacedBy(txId: string): string | undefined {
 | 
			
		||||
    return this.replacedBy[txId];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getReplaces(txId: string): string[] | undefined {
 | 
			
		||||
    return this.replaces[txId];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getTx(txId: string): TransactionExtended | undefined {
 | 
			
		||||
    return this.txs[txId];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // flag a transaction as removed from the mempool
 | 
			
		||||
  public evict(txid): void {
 | 
			
		||||
    this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 txid in this.expiring) {
 | 
			
		||||
      if (this.expiring[txid] < currentDate) {
 | 
			
		||||
        delete this.expiring[txid];
 | 
			
		||||
        this.remove(txid);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 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]) {
 | 
			
		||||
      const replaces = this.replaces[txid];
 | 
			
		||||
      delete this.replaces[txid];
 | 
			
		||||
      for (const tx of replaces) {
 | 
			
		||||
        // recursively remove prior versions from the cache
 | 
			
		||||
        delete this.replacedBy[tx];
 | 
			
		||||
        delete this.txs[tx];
 | 
			
		||||
        this.remove(tx);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -58,10 +58,10 @@ 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']);
 | 
			
		||||
                if (rbfCacheTx) {
 | 
			
		||||
                const rbfCacheTxid = rbfCache.getReplacedBy(client['track-tx']);
 | 
			
		||||
                if (rbfCacheTxid) {
 | 
			
		||||
                  response['txReplaced'] = {
 | 
			
		||||
                    txid: rbfCacheTx.txid,
 | 
			
		||||
                    txid: rbfCacheTxid,
 | 
			
		||||
                  };
 | 
			
		||||
                  client['track-tx'] = null;
 | 
			
		||||
                } else {
 | 
			
		||||
@ -467,6 +467,7 @@ class WebsocketHandler {
 | 
			
		||||
    for (const txId of txIds) {
 | 
			
		||||
      delete _memPool[txId];
 | 
			
		||||
      removed.push(txId);
 | 
			
		||||
      rbfCache.evict(txId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,14 @@
 | 
			
		||||
      <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">
 | 
			
		||||
        <app-truncate [text]="replaced" [lastChars]="12" *ngFor="let replaced of rbfReplaces" [link]="['/tx/' | relativeUrl, replaced]"></app-truncate>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <ng-container *ngIf="!rbfTransaction || rbfTransaction?.size || tx">
 | 
			
		||||
      <h1 i18n="shared.transaction">Transaction</h1>
 | 
			
		||||
 | 
			
		||||
      <span class="tx-link">
 | 
			
		||||
@ -25,7 +32,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 +98,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 +110,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">
 | 
			
		||||
 | 
			
		||||
@ -205,3 +205,9 @@
 | 
			
		||||
    width: 60%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tx-list {
 | 
			
		||||
  .alert-link {
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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();
 | 
			
		||||
 | 
			
		||||
@ -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');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user