From 639294d319b8cc029d8d018576294f91e80eb0d9 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 28 Nov 2022 13:20:51 +0900 Subject: [PATCH] record & display transaction first seen times --- backend/src/api/bitcoin/bitcoin.routes.ts | 37 ++++++++++++ backend/src/api/database-migration.ts | 7 ++- backend/src/api/mempool.ts | 9 +++ backend/src/api/websocket-handler.ts | 3 + backend/src/mempool.interfaces.ts | 4 ++ .../src/repositories/TransactionRepository.ts | 58 ++++++++++++++++--- .../transaction/transaction.component.html | 20 ++++++- .../transaction/transaction.component.ts | 5 +- .../src/app/interfaces/node-api.interface.ts | 4 ++ frontend/src/app/services/api.service.ts | 6 +- 10 files changed, 141 insertions(+), 12 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 55500d0c9..931ddce39 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -25,6 +25,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', this.getTransactionTimes) .get(config.MEMPOOL.API_URL_PREFIX + 'outspends', this.$getBatchedOutspends) .get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.$getCpfpInfo) + .get(config.MEMPOOL.API_URL_PREFIX + 'extras/:txId', this.$getTransactionExtras) .get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', this.getDifficultyChange) .get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', this.getRecommendedFees) .get(config.MEMPOOL.API_URL_PREFIX + 'fees/mempool-blocks', this.getMempoolBlocks) @@ -221,6 +222,42 @@ class BitcoinRoutes { res.status(404).send(`Transaction has no CPFP info available.`); } + private async $getTransactionExtras(req: Request, res: Response): Promise { + if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) { + res.status(501).send(`Invalid transaction ID.`); + return; + } + + const tx = mempool.getMempool()[req.params.txId]; + if (tx) { + if (tx?.cpfpChecked) { + res.json({ + ancestors: tx.ancestors, + bestDescendant: tx.bestDescendant || null, + descendants: tx.descendants || null, + effectiveFeePerVsize: tx.effectiveFeePerVsize || null, + firstSeen: tx.firstSeen, + }); + return; + } + + const cpfpInfo = Common.setRelativesAndGetCpfpInfo(tx, mempool.getMempool()); + + res.json({ + ...cpfpInfo, + firstSeen: tx.firstSeen, + }); + return; + } else { + const extras = await transactionRepository.$getTransactionExtras(req.params.txId); + if (extras) { + res.json(extras); + return; + } + } + res.status(404).send(`Transaction has no extra info available.`); + } + private getBackendInfo(req: Request, res: Response) { res.json(backendInfo.getBackendInfo()); } diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 6e0e95699..77203114d 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -4,7 +4,7 @@ import logger from '../logger'; import { Common } from './common'; class DatabaseMigration { - private static currentVersion = 49; + private static currentVersion = 50; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -442,6 +442,11 @@ class DatabaseMigration { await this.$executeQuery('TRUNCATE TABLE `blocks_audits`'); await this.updateToSchemaVersion(49); } + + if (databaseSchemaVersion < 50 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `transactions` ADD first_seen datetime DEFAULT NULL'); + await this.updateToSchemaVersion(49); + } } /** diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 717f4eebb..665f6f497 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -9,6 +9,7 @@ import loadingIndicators from './loading-indicators'; import bitcoinClient from './bitcoin/bitcoin-client'; import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; import rbfCache from './rbf-cache'; +import transactionRepository from '../repositories/TransactionRepository'; class Mempool { private static WEBSOCKET_REFRESH_RATE_MS = 10000; @@ -217,6 +218,14 @@ class Mempool { } } + public async $saveTxFirstSeenTimes(transactions: TransactionExtended[], mempool: { [txid: string]: TransactionExtended }) { + for (const tx of transactions) { + if (mempool[tx.txid]) { + await transactionRepository.$saveTxFirstSeen(tx.txid, tx.firstSeen || Date.now()); + } + } + } + private updateTxPerSecond() { const nowMinusTimeSpan = new Date().getTime() - (1000 * config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD); this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index b6f32aa05..0e5adacb6 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -19,6 +19,7 @@ import feeApi from './fee-api'; import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; import Audit from './audit'; +import mempool from './mempool'; class WebsocketHandler { private wss: WebSocket.Server | undefined; @@ -462,6 +463,8 @@ class WebsocketHandler { } } + await mempool.$saveTxFirstSeenTimes(transactions, _memPool); + const removed: string[] = []; // Update mempool to remove transactions included in the new block for (const txId of txIds) { diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 046083322..6b046226f 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -136,6 +136,10 @@ export interface CpfpInfo { effectiveFeePerVsize?: number; } +export interface TransactionExtras extends CpfpInfo { + firstSeen?: number; +} + export interface TransactionStripped { txid: string; fee: number; diff --git a/backend/src/repositories/TransactionRepository.ts b/backend/src/repositories/TransactionRepository.ts index 74debb833..474bea0c6 100644 --- a/backend/src/repositories/TransactionRepository.ts +++ b/backend/src/repositories/TransactionRepository.ts @@ -1,14 +1,15 @@ import DB from '../database'; import logger from '../logger'; -import { Ancestor, CpfpInfo } from '../mempool.interfaces'; +import { Ancestor, CpfpInfo, TransactionExtended, TransactionExtras } from '../mempool.interfaces'; -interface CpfpSummary { +interface TxInfo { txid: string; cluster: string; root: string; txs: Ancestor[]; height: number; fee_rate: number; + firstSeen: number; } class TransactionRepository { @@ -33,6 +34,46 @@ class TransactionRepository { } } + public async $saveTxFirstSeen(txid: string, seenAt: number) { + try { + await DB.query( + ` + INSERT INTO transactions + ( + txid, + first_seen + ) + VALUE (?, FROM_UNIXTIME(?)) + ON DUPLICATE KEY UPDATE + first_seen = FROM_UNIXTIME(?) + ;`, + [txid, seenAt, seenAt] + ); + } catch (e: any) { + logger.err(`Cannot save transaction first seen time into db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + + public async $getTransactionExtras(txid: string): Promise { + try { + let query = ` + SELECT *, UNIX_TIMESTAMP(first_seen) as firstSeen + FROM transactions + LEFT JOIN cpfp_clusters AS cluster ON cluster.root = transactions.cluster + WHERE transactions.txid = ? + `; + const [rows]: any = await DB.query(query, [txid]); + if (rows.length) { + rows[0].txs = JSON.parse(rows[0].txs) as Ancestor[]; + return this.convertCpfp(rows[0]); + } + } catch (e) { + logger.err('Cannot get transaction cpfp info from db. Reason: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + public async $getCpfpInfo(txid: string): Promise { try { let query = ` @@ -54,12 +95,12 @@ class TransactionRepository { } } - private convertCpfp(cpfp: CpfpSummary): CpfpInfo { + private convertCpfp(info: TxInfo): TransactionExtras { const descendants: Ancestor[] = []; const ancestors: Ancestor[] = []; let matched = false; - for (const tx of cpfp.txs) { - if (tx.txid === cpfp.txid) { + for (const tx of (info.txs || [])) { + if (tx.txid === info.txid) { matched = true; } else if (!matched) { descendants.push(tx); @@ -68,9 +109,10 @@ class TransactionRepository { } } return { - descendants, - ancestors, - effectiveFeePerVsize: cpfp.fee_rate + descendants: descendants?.length ? descendants : undefined, + ancestors: ancestors, + effectiveFeePerVsize: info.fee_rate, + firstSeen: info.firstSeen || undefined, }; } } diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index b6ed2868f..86ae7c40c 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -54,6 +54,18 @@ + + + + + + + + First seen + + + + Included in block @@ -66,7 +78,7 @@ - + Features @@ -497,6 +509,12 @@ + + Features + + + + diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 575c00637..9b26f298a 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -110,7 +110,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { .pipe( switchMap((txId) => this.apiService - .getCpfpinfo$(txId) + .getTransactionExtras$(txId) .pipe(retryWhen((errors) => errors.pipe( mergeMap((error) => { if (!this.tx?.status || this.tx.status.confirmed) { @@ -156,6 +156,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { txFeePerVSize: this.tx.effectiveFeePerVsize, }); } + if (cpfpInfo.firstSeen) { + this.transactionTime = cpfpInfo.firstSeen; + } this.cpfpInfo = cpfpInfo; }); diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 2e6b94988..de1779af5 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -27,6 +27,10 @@ export interface CpfpInfo { effectiveFeePerVsize?: number; } +export interface TransactionExtras extends CpfpInfo { + firstSeen?: number; +} + export interface DifficultyAdjustment { progressPercent: number; difficultyChange: number; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index f813959e3..e1f0c6121 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 } from '@angular/common/http'; import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, - PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore } from '../interfaces/node-api.interface'; + PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, TransactionExtras } from '../interfaces/node-api.interface'; import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; @@ -115,6 +115,10 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/cpfp/' + txid); } + getTransactionExtras$(txid: string): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/extras/' + txid); + } + validateAddress$(address: string): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/validate-address/' + address); }