diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index ac848d4a4..742ffe242 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -42,6 +42,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary) + .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/tx/:txid/audit', this.$getBlockTxAuditSummary) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight) .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) @@ -361,6 +362,20 @@ class BitcoinRoutes { } } + private async $getBlockTxAuditSummary(req: Request, res: Response) { + try { + const auditSummary = await blocks.$getBlockTxAuditSummary(req.params.hash, req.params.txid); + if (auditSummary) { + res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); + res.json(auditSummary); + } else { + return res.status(404).send(`transaction audit not available`); + } + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async getBlocks(req: Request, res: Response) { try { if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 97db07027..9cc9233d5 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -2,7 +2,7 @@ import config from '../config'; import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; import logger from '../logger'; import memPool from './mempool'; -import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified, BlockAudit } from '../mempool.interfaces'; +import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified, BlockAudit, TransactionAudit } from '../mempool.interfaces'; import { Common } from './common'; import diskCache from './disk-cache'; import transactionUtils from './transaction-utils'; @@ -1359,6 +1359,14 @@ class Blocks { } } + public async $getBlockTxAuditSummary(hash: string, txid: string): Promise { + if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { + return BlocksAuditsRepository.$getBlockTxAudit(hash, txid); + } else { + return null; + } + } + public getLastDifficultyAdjustmentTime(): number { return this.lastDifficultyAdjustmentTime; } diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 34375604e..5e8026d15 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -42,6 +42,19 @@ export interface BlockAudit { matchRate: number, expectedFees?: number, expectedWeight?: number, + template?: any[]; +} + +export interface TransactionAudit { + seen?: boolean; + expected?: boolean; + added?: boolean; + prioritized?: boolean; + delayed?: number; + accelerated?: boolean; + conflict?: boolean; + coinbase?: boolean; + firstSeen?: number; } export interface AuditScore { diff --git a/backend/src/repositories/BlocksAuditsRepository.ts b/backend/src/repositories/BlocksAuditsRepository.ts index daf1ba52d..1e0d28689 100644 --- a/backend/src/repositories/BlocksAuditsRepository.ts +++ b/backend/src/repositories/BlocksAuditsRepository.ts @@ -1,7 +1,7 @@ import blocks from '../api/blocks'; import DB from '../database'; import logger from '../logger'; -import { BlockAudit, AuditScore } from '../mempool.interfaces'; +import { BlockAudit, AuditScore, TransactionAudit } from '../mempool.interfaces'; class BlocksAuditRepositories { public async $saveAudit(audit: BlockAudit): Promise { @@ -98,6 +98,41 @@ class BlocksAuditRepositories { } } + public async $getBlockTxAudit(hash: string, txid: string): Promise { + try { + const blockAudit = await this.$getBlockAudit(hash); + + if (blockAudit) { + const isAdded = blockAudit.addedTxs.includes(txid); + const isPrioritized = blockAudit.prioritizedTxs.includes(txid); + const isAccelerated = blockAudit.acceleratedTxs.includes(txid); + const isConflict = blockAudit.fullrbfTxs.includes(txid); + let isExpected = false; + let firstSeen = undefined; + blockAudit.template?.forEach(tx => { + if (tx.txid === txid) { + isExpected = true; + firstSeen = tx.time; + } + }); + + return { + seen: isExpected || isPrioritized || isAccelerated, + expected: isExpected, + added: isAdded, + prioritized: isPrioritized, + conflict: isConflict, + accelerated: isAccelerated, + firstSeen, + } + } + return null; + } catch (e: any) { + logger.err(`Cannot fetch block transaction audit from db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } + public async $getBlockAuditScore(hash: string): Promise { try { const [rows]: any[] = await DB.query( diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 67ab8ce01..5e010b8e0 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -42,7 +42,7 @@ interface Pool { slug: string; } -interface AuditStatus { +export interface TxAuditStatus { seen?: boolean; expected?: boolean; added?: boolean; @@ -100,7 +100,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { sigops: number | null; adjustedVsize: number | null; pool: Pool | null; - auditStatus: AuditStatus | null; + auditStatus: TxAuditStatus | null; isAcceleration: boolean = false; filters: Filter[] = []; showCpfpDetails = false; @@ -374,33 +374,41 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { const auditAvailable = this.isAuditAvailable(height); const isCoinbase = this.tx.vin.some(v => v.is_coinbase); const fetchAudit = auditAvailable && !isCoinbase; - return fetchAudit ? this.apiService.getBlockAudit$(hash).pipe( - map(audit => { - const isAdded = audit.addedTxs.includes(txid); - const isPrioritized = audit.prioritizedTxs.includes(txid); - const isAccelerated = audit.acceleratedTxs.includes(txid); - const isConflict = audit.fullrbfTxs.includes(txid); - const isExpected = audit.template.some(tx => tx.txid === txid); - const firstSeen = audit.template.find(tx => tx.txid === txid)?.time; - return { - seen: isExpected || isPrioritized || isAccelerated, - expected: isExpected, - added: isAdded, - prioritized: isPrioritized, - conflict: isConflict, - accelerated: isAccelerated, - firstSeen, - }; - }), - retry({ count: 3, delay: 2000 }), - catchError(() => { - return of(null); - }) - ) : of(isCoinbase ? { coinbase: true } : null); + if (fetchAudit) { + // If block audit is already cached, use it to get transaction audit + const blockAuditLoaded = this.apiService.getBlockAuditLoaded(hash); + if (blockAuditLoaded) { + return this.apiService.getBlockAudit$(hash).pipe( + map(audit => { + const isAdded = audit.addedTxs.includes(txid); + const isPrioritized = audit.prioritizedTxs.includes(txid); + const isAccelerated = audit.acceleratedTxs.includes(txid); + const isConflict = audit.fullrbfTxs.includes(txid); + const isExpected = audit.template.some(tx => tx.txid === txid); + const firstSeen = audit.template.find(tx => tx.txid === txid)?.time; + return { + seen: isExpected || isPrioritized || isAccelerated, + expected: isExpected, + added: isAdded, + prioritized: isPrioritized, + conflict: isConflict, + accelerated: isAccelerated, + firstSeen, + }; + }) + ) + } else { + return this.apiService.getBlockTxAudit$(hash, txid).pipe( + retry({ count: 3, delay: 2000 }), + catchError(() => { + return of(null); + }) + ) + } + } else { + return of(isCoinbase ? { coinbase: true } : null); + } }), - catchError((e) => { - return of(null); - }) ).subscribe(auditStatus => { this.auditStatus = auditStatus; if (this.auditStatus?.firstSeen) { diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 6b0d60ccf..d7efa4d02 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -8,6 +8,7 @@ import { Transaction } from '../interfaces/electrs.interface'; import { Conversion } from './price.service'; import { StorageService } from './storage.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; +import { TxAuditStatus } from '../components/transaction/transaction.component'; @Injectable({ providedIn: 'root' @@ -17,6 +18,7 @@ export class ApiService { private apiBasePath: string; // network path is /testnet, etc. or '' for mainnet private requestCache = new Map, expiry: number }>; + public blockAuditLoaded: { [hash: string]: boolean } = {}; constructor( private httpClient: HttpClient, @@ -369,11 +371,18 @@ export class ApiService { } getBlockAudit$(hash: string) : Observable { + this.setBlockAuditLoaded(hash); return this.httpClient.get( this.apiBaseUrl + this.apiBasePath + `/api/v1/block/${hash}/audit-summary` ); } + getBlockTxAudit$(hash: string, txid: string) : Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/block/${hash}/tx/${txid}/audit` + ); + } + getBlockAuditScores$(from: number): Observable { return this.httpClient.get( this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/scores` + @@ -526,4 +535,13 @@ export class ApiService { this.apiBaseUrl + this.apiBasePath + '/api/v1/accelerations/total' + (queryString?.length ? '?' + queryString : '') ); } + + // Cache methods + async setBlockAuditLoaded(hash: string) { + this.blockAuditLoaded[hash] = true; + } + + getBlockAuditLoaded(hash) { + return this.blockAuditLoaded[hash]; + } } diff --git a/frontend/src/app/services/cache.service.ts b/frontend/src/app/services/cache.service.ts index 993fcdfc6..f15154b46 100644 --- a/frontend/src/app/services/cache.service.ts +++ b/frontend/src/app/services/cache.service.ts @@ -124,6 +124,7 @@ export class CacheService { resetBlockCache() { this.blockHashCache = {}; this.blockCache = {}; + this.apiService.blockAuditLoaded = {}; this.blockLoading = {}; this.copiesInBlockQueue = {}; this.blockPriorities = [];