diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 3b33c1ead..d2d298e09 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 + 'blocks/:height', this.getBlocks.bind(this)) .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/tx/:txid/summary', this.getStrippedBlockTransaction) .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) @@ -321,6 +322,20 @@ class BitcoinRoutes { } } + private async getStrippedBlockTransaction(req: Request, res: Response) { + try { + const transaction = await blocks.$getSingleTxFromSummary(req.params.hash, req.params.txid); + if (!transaction) { + handleError(req, res, 404, `transaction not found in summary`); + return; + } + res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); + res.json(transaction); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async getBlock(req: Request, res: Response) { try { const block = await blocks.$getBlock(req.params.hash); diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 3420d99c8..e621056ab 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -1224,6 +1224,11 @@ class Blocks { return summary.transactions; } + public async $getSingleTxFromSummary(hash: string, txid: string): Promise { + const txs = await this.$getStrippedBlockTransactions(hash); + return txs.find(tx => tx.txid === txid) || null; + } + /** * Get 15 blocks * diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 5f5ef4fa7..f19a5bcbd 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -406,6 +406,30 @@ 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; + + const addFirstSeen = (audit: TxAuditStatus | null, hash: string, height: number, txid: string, useFullSummary: boolean) => { + if ( + this.isFirstSeenAvailable(height) + && !audit?.firstSeen // firstSeen is not already in audit + && (!audit || audit?.seen) // audit is disabled or tx is already seen (meaning 'firstSeen' is in block summary) + ) { + return useFullSummary ? + this.apiService.getStrippedBlockTransactions$(hash).pipe( + map(strippedTxs => { + return { audit, firstSeen: strippedTxs.find(tx => tx.txid === txid)?.time }; + }), + catchError(() => of({ audit })) + ) : + this.apiService.getStrippedBlockTransaction$(hash, txid).pipe( + map(strippedTx => { + return { audit, firstSeen: strippedTx?.time }; + }), + catchError(() => of({ audit })) + ); + } + return of({ audit }); + }; + if (fetchAudit) { // If block audit is already cached, use it to get transaction audit const blockAuditLoaded = this.apiService.getBlockAuditLoaded(hash); @@ -428,24 +452,31 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { accelerated: isAccelerated, firstSeen, }; + }), + switchMap(audit => addFirstSeen(audit, hash, height, txid, true)), + catchError(() => { + return of({ audit: null }); }) ) } else { return this.apiService.getBlockTxAudit$(hash, txid).pipe( retry({ count: 3, delay: 2000 }), + switchMap(audit => addFirstSeen(audit, hash, height, txid, false)), catchError(() => { - return of(null); + return of({ audit: null }); }) ) } } else { - return of(isCoinbase ? { coinbase: true } : null); + const audit = isCoinbase ? { coinbase: true } : null; + return addFirstSeen(audit, hash, height, txid, this.apiService.getBlockSummaryLoaded(hash)); } }), ).subscribe(auditStatus => { - this.auditStatus = auditStatus; - if (this.auditStatus?.firstSeen) { - this.transactionTime = this.auditStatus.firstSeen; + this.auditStatus = auditStatus?.audit; + const firstSeen = this.auditStatus?.firstSeen || auditStatus['firstSeen']; + if (firstSeen) { + this.transactionTime = firstSeen; } this.setIsAccelerated(); }); @@ -922,6 +953,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { return false; } break; + case 'testnet4': + if (blockHeight < this.stateService.env.TESTNET4_BLOCK_AUDIT_START_HEIGHT) { + return false; + } + break; case 'signet': if (blockHeight < this.stateService.env.SIGNET_BLOCK_AUDIT_START_HEIGHT) { return false; @@ -935,6 +971,34 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { return true; } + isFirstSeenAvailable(blockHeight: number): boolean { + if (this.stateService.env.BASE_MODULE !== 'mempool') { + return false; + } + switch (this.stateService.network) { + case 'testnet': + if (this.stateService.env.TESTNET_TX_FIRST_SEEN_START_HEIGHT && blockHeight >= this.stateService.env.TESTNET_TX_FIRST_SEEN_START_HEIGHT) { + return true; + } + break; + case 'testnet4': + if (this.stateService.env.TESTNET4_TX_FIRST_SEEN_START_HEIGHT && blockHeight >= this.stateService.env.TESTNET4_TX_FIRST_SEEN_START_HEIGHT) { + return true; + } + break; + case 'signet': + if (this.stateService.env.SIGNET_TX_FIRST_SEEN_START_HEIGHT && blockHeight >= this.stateService.env.SIGNET_TX_FIRST_SEEN_START_HEIGHT) { + return true; + } + break; + default: + if (this.stateService.env.MAINNET_TX_FIRST_SEEN_START_HEIGHT && blockHeight >= this.stateService.env.MAINNET_TX_FIRST_SEEN_START_HEIGHT) { + return true; + } + } + return false; + } + resetTransaction() { this.firstLoad = false; this.gotInitialPosition = false; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 08251ddae..3c8cf8807 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -18,6 +18,7 @@ export class ApiService { private apiBasePath: string; // network path is /testnet, etc. or '' for mainnet private requestCache = new Map, expiry: number }>; + public blockSummaryLoaded: { [hash: string]: boolean } = {}; public blockAuditLoaded: { [hash: string]: boolean } = {}; constructor( @@ -318,9 +319,14 @@ export class ApiService { } getStrippedBlockTransactions$(hash: string): Observable { + this.setBlockSummaryLoaded(hash); return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash + '/summary'); } + getStrippedBlockTransaction$(hash: string, txid: string): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/block/' + hash + '/tx/' + txid + '/summary'); + } + getDifficultyAdjustments$(interval: string | undefined): Observable { return this.httpClient.get( this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/difficulty-adjustments` + @@ -567,4 +573,12 @@ export class ApiService { getBlockAuditLoaded(hash) { return this.blockAuditLoaded[hash]; } + + async setBlockSummaryLoaded(hash: string) { + this.blockSummaryLoaded[hash] = true; + } + + getBlockSummaryLoaded(hash) { + return this.blockSummaryLoaded[hash]; + } } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 8dd17cf75..2feb266d1 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -68,7 +68,12 @@ export interface Env { AUDIT: boolean; MAINNET_BLOCK_AUDIT_START_HEIGHT: number; TESTNET_BLOCK_AUDIT_START_HEIGHT: number; + TESTNET4_BLOCK_AUDIT_START_HEIGHT: number; SIGNET_BLOCK_AUDIT_START_HEIGHT: number; + MAINNET_TX_FIRST_SEEN_START_HEIGHT: number; + TESTNET_TX_FIRST_SEEN_START_HEIGHT: number; + TESTNET4_TX_FIRST_SEEN_START_HEIGHT: number; + SIGNET_TX_FIRST_SEEN_START_HEIGHT: number; HISTORICAL_PRICE: boolean; ACCELERATOR: boolean; ACCELERATOR_BUTTON: boolean; @@ -107,7 +112,12 @@ const defaultEnv: Env = { 'AUDIT': false, 'MAINNET_BLOCK_AUDIT_START_HEIGHT': 0, 'TESTNET_BLOCK_AUDIT_START_HEIGHT': 0, + 'TESTNET4_BLOCK_AUDIT_START_HEIGHT': 0, 'SIGNET_BLOCK_AUDIT_START_HEIGHT': 0, + 'MAINNET_TX_FIRST_SEEN_START_HEIGHT': 0, + 'TESTNET_TX_FIRST_SEEN_START_HEIGHT': 0, + 'TESTNET4_TX_FIRST_SEEN_START_HEIGHT': 0, + 'SIGNET_TX_FIRST_SEEN_START_HEIGHT': 0, 'HISTORICAL_PRICE': true, 'ACCELERATOR': false, 'ACCELERATOR_BUTTON': true, diff --git a/production/mempool-frontend-config.mainnet.json b/production/mempool-frontend-config.mainnet.json index 61a8c2c2a..79acaecc5 100644 --- a/production/mempool-frontend-config.mainnet.json +++ b/production/mempool-frontend-config.mainnet.json @@ -13,6 +13,7 @@ "MAINNET_BLOCK_AUDIT_START_HEIGHT": 773911, "TESTNET_BLOCK_AUDIT_START_HEIGHT": 2417829, "SIGNET_BLOCK_AUDIT_START_HEIGHT": 127609, + "MAINNET_TX_FIRST_SEEN_START_HEIGHT": 838316, "ITEMS_PER_PAGE": 25, "LIGHTNING": true, "ACCELERATOR": true,