Merge pull request #5567 from mempool/natsoni/block-first-seen-audit
Store first seen time in block audit
This commit is contained in:
		
						commit
						26e3a2413d
					
				| @ -46,7 +46,8 @@ | |||||||
|     "PASSWORD": "mempool", |     "PASSWORD": "mempool", | ||||||
|     "TIMEOUT": 60000, |     "TIMEOUT": 60000, | ||||||
|     "COOKIE": false, |     "COOKIE": false, | ||||||
|     "COOKIE_PATH": "/path/to/bitcoin/.cookie" |     "COOKIE_PATH": "/path/to/bitcoin/.cookie", | ||||||
|  |     "DEBUG_LOG_PATH": "/path/to/bitcoin/debug.log" | ||||||
|   }, |   }, | ||||||
|   "ELECTRUM": { |   "ELECTRUM": { | ||||||
|     "HOST": "127.0.0.1", |     "HOST": "127.0.0.1", | ||||||
|  | |||||||
| @ -47,7 +47,8 @@ | |||||||
|     "PASSWORD": "__CORE_RPC_PASSWORD__", |     "PASSWORD": "__CORE_RPC_PASSWORD__", | ||||||
|     "TIMEOUT": 1000, |     "TIMEOUT": 1000, | ||||||
|     "COOKIE": false, |     "COOKIE": false, | ||||||
|     "COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__" |     "COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__", | ||||||
|  |     "DEBUG_LOG_PATH": "__CORE_RPC_DEBUG_LOG_PATH__" | ||||||
|   }, |   }, | ||||||
|   "ELECTRUM": { |   "ELECTRUM": { | ||||||
|     "HOST": "__ELECTRUM_HOST__", |     "HOST": "__ELECTRUM_HOST__", | ||||||
|  | |||||||
| @ -74,7 +74,8 @@ describe('Mempool Backend Config', () => { | |||||||
|         PASSWORD: 'mempool', |         PASSWORD: 'mempool', | ||||||
|         TIMEOUT: 60000, |         TIMEOUT: 60000, | ||||||
|         COOKIE: false, |         COOKIE: false, | ||||||
|         COOKIE_PATH: '/bitcoin/.cookie' |         COOKIE_PATH: '/bitcoin/.cookie', | ||||||
|  |         DEBUG_LOG_PATH: '', | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|       expect(config.SECOND_CORE_RPC).toStrictEqual({ |       expect(config.SECOND_CORE_RPC).toStrictEqual({ | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; | |||||||
| import { RowDataPacket } from 'mysql2'; | import { RowDataPacket } from 'mysql2'; | ||||||
| 
 | 
 | ||||||
| class DatabaseMigration { | class DatabaseMigration { | ||||||
|   private static currentVersion = 82; |   private static currentVersion = 83; | ||||||
|   private queryTimeout = 3600_000; |   private queryTimeout = 3600_000; | ||||||
|   private statisticsAddedIndexed = false; |   private statisticsAddedIndexed = false; | ||||||
|   private uniqueLogs: string[] = []; |   private uniqueLogs: string[] = []; | ||||||
| @ -705,6 +705,11 @@ class DatabaseMigration { | |||||||
|       await this.$fixBadV1AuditBlocks(); |       await this.$fixBadV1AuditBlocks(); | ||||||
|       await this.updateToSchemaVersion(82); |       await this.updateToSchemaVersion(82); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     if (databaseSchemaVersion < 83 && isBitcoin === true) { | ||||||
|  |       await this.$executeQuery('ALTER TABLE `blocks` ADD first_seen datetime(6) DEFAULT NULL'); | ||||||
|  |       await this.updateToSchemaVersion(83); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|  | |||||||
| @ -16,6 +16,7 @@ import transactionUtils from './transaction-utils'; | |||||||
| import rbfCache, { ReplacementInfo } from './rbf-cache'; | import rbfCache, { ReplacementInfo } from './rbf-cache'; | ||||||
| import difficultyAdjustment from './difficulty-adjustment'; | import difficultyAdjustment from './difficulty-adjustment'; | ||||||
| import feeApi from './fee-api'; | import feeApi from './fee-api'; | ||||||
|  | import BlocksRepository from '../repositories/BlocksRepository'; | ||||||
| import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; | import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; | ||||||
| import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; | import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; | ||||||
| import Audit from './audit'; | import Audit from './audit'; | ||||||
| @ -34,6 +35,7 @@ interface AddressTransactions { | |||||||
| } | } | ||||||
| import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; | import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; | ||||||
| import { calculateMempoolTxCpfp } from './cpfp'; | import { calculateMempoolTxCpfp } from './cpfp'; | ||||||
|  | import { getRecentFirstSeen } from '../utils/file-read'; | ||||||
| 
 | 
 | ||||||
| // valid 'want' subscriptions
 | // valid 'want' subscriptions
 | ||||||
| const wantable = [ | const wantable = [ | ||||||
| @ -1028,6 +1030,14 @@ class WebsocketHandler { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (config.CORE_RPC.DEBUG_LOG_PATH && block.extras) { | ||||||
|  |       const firstSeen = getRecentFirstSeen(block.id); | ||||||
|  |       if (firstSeen) { | ||||||
|  |         BlocksRepository.$saveFirstSeenTime(block.id, firstSeen); | ||||||
|  |         block.extras.firstSeen = firstSeen; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const confirmedTxids: { [txid: string]: boolean } = {}; |     const confirmedTxids: { [txid: string]: boolean } = {}; | ||||||
| 
 | 
 | ||||||
|     // Update mempool to remove transactions included in the new block
 |     // Update mempool to remove transactions included in the new block
 | ||||||
|  | |||||||
| @ -86,6 +86,7 @@ interface IConfig { | |||||||
|     TIMEOUT: number; |     TIMEOUT: number; | ||||||
|     COOKIE: boolean; |     COOKIE: boolean; | ||||||
|     COOKIE_PATH: string; |     COOKIE_PATH: string; | ||||||
|  |     DEBUG_LOG_PATH: string; | ||||||
|   }; |   }; | ||||||
|   SECOND_CORE_RPC: { |   SECOND_CORE_RPC: { | ||||||
|     HOST: string; |     HOST: string; | ||||||
| @ -227,7 +228,8 @@ const defaults: IConfig = { | |||||||
|     'PASSWORD': 'mempool', |     'PASSWORD': 'mempool', | ||||||
|     'TIMEOUT': 60000, |     'TIMEOUT': 60000, | ||||||
|     'COOKIE': false, |     'COOKIE': false, | ||||||
|     'COOKIE_PATH': '/bitcoin/.cookie' |     'COOKIE_PATH': '/bitcoin/.cookie', | ||||||
|  |     'DEBUG_LOG_PATH': '', | ||||||
|   }, |   }, | ||||||
|   'SECOND_CORE_RPC': { |   'SECOND_CORE_RPC': { | ||||||
|     'HOST': '127.0.0.1', |     'HOST': '127.0.0.1', | ||||||
|  | |||||||
| @ -320,6 +320,7 @@ export interface BlockExtension { | |||||||
|   segwitTotalSize: number; |   segwitTotalSize: number; | ||||||
|   segwitTotalWeight: number; |   segwitTotalWeight: number; | ||||||
|   header: string; |   header: string; | ||||||
|  |   firstSeen: number | null; | ||||||
|   utxoSetChange: number; |   utxoSetChange: number; | ||||||
|   // Requires coinstatsindex, will be set to NULL otherwise
 |   // Requires coinstatsindex, will be set to NULL otherwise
 | ||||||
|   utxoSetSize: number | null; |   utxoSetSize: number | null; | ||||||
|  | |||||||
| @ -57,6 +57,7 @@ interface DatabaseBlock { | |||||||
|   utxoSetChange: number; |   utxoSetChange: number; | ||||||
|   utxoSetSize: number; |   utxoSetSize: number; | ||||||
|   totalInputAmt: number; |   totalInputAmt: number; | ||||||
|  |   firstSeen: number; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const BLOCK_DB_FIELDS = ` | const BLOCK_DB_FIELDS = ` | ||||||
| @ -99,7 +100,8 @@ const BLOCK_DB_FIELDS = ` | |||||||
|   blocks.header, |   blocks.header, | ||||||
|   blocks.utxoset_change AS utxoSetChange, |   blocks.utxoset_change AS utxoSetChange, | ||||||
|   blocks.utxoset_size AS utxoSetSize, |   blocks.utxoset_size AS utxoSetSize, | ||||||
|   blocks.total_input_amt AS totalInputAmt |   blocks.total_input_amt AS totalInputAmt, | ||||||
|  |   UNIX_TIMESTAMP(blocks.first_seen) AS firstSeen | ||||||
| `;
 | `;
 | ||||||
| 
 | 
 | ||||||
| class BlocksRepository { | class BlocksRepository { | ||||||
| @ -1021,6 +1023,24 @@ class BlocksRepository { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * Save block first seen time | ||||||
|  |    *  | ||||||
|  |    * @param id  | ||||||
|  |    */ | ||||||
|  |   public async $saveFirstSeenTime(id: string, firstSeen: number): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       await DB.query(` | ||||||
|  |         UPDATE blocks SET first_seen = FROM_UNIXTIME(?) | ||||||
|  |         WHERE hash = ?`,
 | ||||||
|  |         [firstSeen, id] | ||||||
|  |       ); | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.err(`Cannot update block first seen time. Reason: ` + (e instanceof Error ? e.message : e)); | ||||||
|  |       throw e; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * Convert a mysql row block into a BlockExtended. Note that you |    * Convert a mysql row block into a BlockExtended. Note that you | ||||||
|    * must provide the correct field into dbBlk object param |    * must provide the correct field into dbBlk object param | ||||||
| @ -1078,6 +1098,7 @@ class BlocksRepository { | |||||||
|     extras.utxoSetSize = dbBlk.utxoSetSize; |     extras.utxoSetSize = dbBlk.utxoSetSize; | ||||||
|     extras.totalInputAmt = dbBlk.totalInputAmt; |     extras.totalInputAmt = dbBlk.totalInputAmt; | ||||||
|     extras.virtualSize = dbBlk.weight / 4.0; |     extras.virtualSize = dbBlk.weight / 4.0; | ||||||
|  |     extras.firstSeen = dbBlk.firstSeen; | ||||||
| 
 | 
 | ||||||
|     // Re-org can happen after indexing so we need to always get the
 |     // Re-org can happen after indexing so we need to always get the
 | ||||||
|     // latest state from core
 |     // latest state from core
 | ||||||
|  | |||||||
							
								
								
									
										58
									
								
								backend/src/utils/file-read.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								backend/src/utils/file-read.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | |||||||
|  | import * as fs from 'fs'; | ||||||
|  | import logger from '../logger'; | ||||||
|  | import config from '../config'; | ||||||
|  | 
 | ||||||
|  | function readFile(filePath: string, bufferSize?: number): string[] { | ||||||
|  |   const fileSize = fs.statSync(filePath).size; | ||||||
|  |   const chunkSize = bufferSize || fileSize; | ||||||
|  |   const fileDescriptor = fs.openSync(filePath, 'r'); | ||||||
|  |   const buffer = Buffer.alloc(chunkSize); | ||||||
|  | 
 | ||||||
|  |   fs.readSync(fileDescriptor, buffer, 0, chunkSize, fileSize - chunkSize); | ||||||
|  |   fs.closeSync(fileDescriptor); | ||||||
|  | 
 | ||||||
|  |   const lines = buffer.toString('utf8', 0, chunkSize).split('\n'); | ||||||
|  |   return lines; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function extractDateFromLogLine(line: string): number | undefined { | ||||||
|  |   // Extract time from log: "2021-08-31T12:34:56Z" or "2021-08-31T12:34:56.123456Z"
 | ||||||
|  |   const dateMatch = line.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{6})?Z/); | ||||||
|  |   if (!dateMatch) { | ||||||
|  |     return undefined; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const dateStr = dateMatch[0]; | ||||||
|  |   const date = new Date(dateStr); | ||||||
|  |   let timestamp = Math.floor(date.getTime() / 1000); // Remove decimal (microseconds are added later)
 | ||||||
|  | 
 | ||||||
|  |   const timePart = dateStr.split('T')[1]; | ||||||
|  |   const microseconds = timePart.split('.')[1] || ''; | ||||||
|  | 
 | ||||||
|  |   if (!microseconds) { | ||||||
|  |     return timestamp; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return parseFloat(timestamp + '.' + microseconds); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function getRecentFirstSeen(hash: string): number | undefined { | ||||||
|  |   const debugLogPath = config.CORE_RPC.DEBUG_LOG_PATH; | ||||||
|  |   if (debugLogPath) { | ||||||
|  |     try { | ||||||
|  |       // Read the last few lines of debug.log
 | ||||||
|  |       const lines = readFile(debugLogPath, 2048); | ||||||
|  | 
 | ||||||
|  |       for (let i = lines.length - 1; i >= 0; i--) { | ||||||
|  |         const line = lines[i]; | ||||||
|  |         if (line && line.includes(`Saw new header hash=${hash}`)) { | ||||||
|  |           return extractDateFromLogLine(line); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.err(`Cannot parse block first seen time from Core logs. Reason: ` + (e instanceof Error ? e.message : e)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return undefined; | ||||||
|  | } | ||||||
| @ -47,7 +47,8 @@ | |||||||
|     "PASSWORD": "__CORE_RPC_PASSWORD__", |     "PASSWORD": "__CORE_RPC_PASSWORD__", | ||||||
|     "TIMEOUT": __CORE_RPC_TIMEOUT__, |     "TIMEOUT": __CORE_RPC_TIMEOUT__, | ||||||
|     "COOKIE": __CORE_RPC_COOKIE__, |     "COOKIE": __CORE_RPC_COOKIE__, | ||||||
|     "COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__" |     "COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__", | ||||||
|  |     "DEBUG_LOG_PATH": "__CORE_RPC_DEBUG_LOG_PATH__" | ||||||
|   }, |   }, | ||||||
|   "ELECTRUM": { |   "ELECTRUM": { | ||||||
|     "HOST": "__ELECTRUM_HOST__", |     "HOST": "__ELECTRUM_HOST__", | ||||||
|  | |||||||
| @ -49,6 +49,7 @@ __CORE_RPC_PASSWORD__=${CORE_RPC_PASSWORD:=mempool} | |||||||
| __CORE_RPC_TIMEOUT__=${CORE_RPC_TIMEOUT:=60000} | __CORE_RPC_TIMEOUT__=${CORE_RPC_TIMEOUT:=60000} | ||||||
| __CORE_RPC_COOKIE__=${CORE_RPC_COOKIE:=false} | __CORE_RPC_COOKIE__=${CORE_RPC_COOKIE:=false} | ||||||
| __CORE_RPC_COOKIE_PATH__=${CORE_RPC_COOKIE_PATH:=""} | __CORE_RPC_COOKIE_PATH__=${CORE_RPC_COOKIE_PATH:=""} | ||||||
|  | __CORE_RPC_DEBUG_LOG_PATH__=${CORE_RPC_DEBUG_LOG_PATH:=""} | ||||||
| 
 | 
 | ||||||
| # ELECTRUM | # ELECTRUM | ||||||
| __ELECTRUM_HOST__=${ELECTRUM_HOST:=127.0.0.1} | __ELECTRUM_HOST__=${ELECTRUM_HOST:=127.0.0.1} | ||||||
| @ -207,6 +208,7 @@ sed -i "s!__CORE_RPC_PASSWORD__!${__CORE_RPC_PASSWORD__}!g" mempool-config.json | |||||||
| sed -i "s!__CORE_RPC_TIMEOUT__!${__CORE_RPC_TIMEOUT__}!g" mempool-config.json | sed -i "s!__CORE_RPC_TIMEOUT__!${__CORE_RPC_TIMEOUT__}!g" mempool-config.json | ||||||
| sed -i "s!__CORE_RPC_COOKIE__!${__CORE_RPC_COOKIE__}!g" mempool-config.json | sed -i "s!__CORE_RPC_COOKIE__!${__CORE_RPC_COOKIE__}!g" mempool-config.json | ||||||
| sed -i "s!__CORE_RPC_COOKIE_PATH__!${__CORE_RPC_COOKIE_PATH__}!g" mempool-config.json | sed -i "s!__CORE_RPC_COOKIE_PATH__!${__CORE_RPC_COOKIE_PATH__}!g" mempool-config.json | ||||||
|  | sed -i "s!__CORE_RPC_DEBUG_LOG_PATH__!${__CORE_RPC_DEBUG_LOG_PATH__}!g" mempool-config.json | ||||||
| 
 | 
 | ||||||
| sed -i "s!__ELECTRUM_HOST__!${__ELECTRUM_HOST__}!g" mempool-config.json | sed -i "s!__ELECTRUM_HOST__!${__ELECTRUM_HOST__}!g" mempool-config.json | ||||||
| sed -i "s!__ELECTRUM_PORT__!${__ELECTRUM_PORT__}!g" mempool-config.json | sed -i "s!__ELECTRUM_PORT__!${__ELECTRUM_PORT__}!g" mempool-config.json | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user