Merge branch 'master' into mononaut/effective-rate-templates
This commit is contained in:
		
						commit
						5f582195ad
					
				
							
								
								
									
										4
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @ -9,7 +9,7 @@ jobs: | |||||||
|     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" |     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" | ||||||
|     strategy: |     strategy: | ||||||
|       matrix: |       matrix: | ||||||
|         node: ["16.16.0", "18.14.1"] |         node: ["16", "17", "18"] | ||||||
|         flavor: ["dev", "prod"] |         flavor: ["dev", "prod"] | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|     runs-on: "ubuntu-latest" |     runs-on: "ubuntu-latest" | ||||||
| @ -55,7 +55,7 @@ jobs: | |||||||
|     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" |     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" | ||||||
|     strategy: |     strategy: | ||||||
|       matrix: |       matrix: | ||||||
|         node: ["16.16.0", "18.14.1"] |         node: ["16", "17", "18"] | ||||||
|         flavor: ["dev", "prod"] |         flavor: ["dev", "prod"] | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|     runs-on: "ubuntu-latest" |     runs-on: "ubuntu-latest" | ||||||
|  | |||||||
| @ -224,7 +224,12 @@ class BitcoinRoutes { | |||||||
|     } else { |     } else { | ||||||
|       let cpfpInfo; |       let cpfpInfo; | ||||||
|       if (config.DATABASE.ENABLED) { |       if (config.DATABASE.ENABLED) { | ||||||
|         cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); |         try { | ||||||
|  |           cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); | ||||||
|  |         } catch (e) { | ||||||
|  |           res.status(500).send('failed to get CPFP info'); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|       if (cpfpInfo) { |       if (cpfpInfo) { | ||||||
|         res.json(cpfpInfo); |         res.json(cpfpInfo); | ||||||
|  | |||||||
| @ -738,6 +738,11 @@ class Blocks { | |||||||
|         this.currentDifficulty = block.difficulty; |         this.currentDifficulty = block.difficulty; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       // wait for pending async callbacks to finish
 | ||||||
|  |       this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`); | ||||||
|  |       await Promise.all(callbackPromises); | ||||||
|  |       this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`); | ||||||
|  | 
 | ||||||
|       this.blocks.push(blockExtended); |       this.blocks.push(blockExtended); | ||||||
|       if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) { |       if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) { | ||||||
|         this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4); |         this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4); | ||||||
| @ -754,11 +759,6 @@ class Blocks { | |||||||
|         diskCache.$saveCacheToDisk(); |         diskCache.$saveCacheToDisk(); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       // wait for pending async callbacks to finish
 |  | ||||||
|       this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`); |  | ||||||
|       await Promise.all(callbackPromises); |  | ||||||
|       this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`); |  | ||||||
| 
 |  | ||||||
|       handledBlocks++; |       handledBlocks++; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -1072,9 +1072,13 @@ class Blocks { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> { |   public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> { | ||||||
|     const result = await cpfpRepository.$batchSaveClusters(cpfpSummary.clusters); |     try { | ||||||
|     if (!result) { |       const result = await cpfpRepository.$batchSaveClusters(cpfpSummary.clusters); | ||||||
|       await cpfpRepository.$insertProgressMarker(height); |       if (!result) { | ||||||
|  |         await cpfpRepository.$insertProgressMarker(height); | ||||||
|  |       } | ||||||
|  |     } catch (e) { | ||||||
|  |       // not a fatal error, we'll try again next time the indexer runs
 | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -143,7 +143,7 @@ class MempoolBlocks { | |||||||
|           const stackWeight = transactionsSorted.slice(index).reduce((total, tx) => total + (tx.weight || 0), 0); |           const stackWeight = transactionsSorted.slice(index).reduce((total, tx) => total + (tx.weight || 0), 0); | ||||||
|           if (stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS) { |           if (stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS) { | ||||||
|             onlineStats = true; |             onlineStats = true; | ||||||
|             feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5); |             feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]); | ||||||
|             feeStatsCalculator.processNext(tx); |             feeStatsCalculator.processNext(tx); | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
| @ -334,7 +334,7 @@ class MempoolBlocks { | |||||||
|     if (hasBlockStack) { |     if (hasBlockStack) { | ||||||
|       stackWeight = blocks[blocks.length - 1].reduce((total, tx) => total + (mempool[tx]?.weight || 0), 0); |       stackWeight = blocks[blocks.length - 1].reduce((total, tx) => total + (mempool[tx]?.weight || 0), 0); | ||||||
|       hasBlockStack = stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS; |       hasBlockStack = stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS; | ||||||
|       feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5); |       feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees, feeStats }[] = []; |     const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees, feeStats }[] = []; | ||||||
|  | |||||||
| @ -211,7 +211,7 @@ class StatisticsApi { | |||||||
|       CAST(avg(vsize_1800) as DOUBLE) as vsize_1800, |       CAST(avg(vsize_1800) as DOUBLE) as vsize_1800, | ||||||
|       CAST(avg(vsize_2000) as DOUBLE) as vsize_2000 \ |       CAST(avg(vsize_2000) as DOUBLE) as vsize_2000 \ | ||||||
|       FROM statistics \ |       FROM statistics \ | ||||||
|       WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \ |       ${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \ | ||||||
|       GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \ |       GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \ | ||||||
|       ORDER BY statistics.added DESC;`;
 |       ORDER BY statistics.added DESC;`;
 | ||||||
|   } |   } | ||||||
| @ -259,7 +259,7 @@ class StatisticsApi { | |||||||
|       vsize_1800, |       vsize_1800, | ||||||
|       vsize_2000 \ |       vsize_2000 \ | ||||||
|       FROM statistics \ |       FROM statistics \ | ||||||
|       WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \ |       ${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \ | ||||||
|       GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \ |       GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \ | ||||||
|       ORDER BY statistics.added DESC;`;
 |       ORDER BY statistics.added DESC;`;
 | ||||||
|   } |   } | ||||||
| @ -386,6 +386,17 @@ class StatisticsApi { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   public async $listAll(): Promise<OptimizedStatistic[]> { | ||||||
|  |     try { | ||||||
|  |       const query = this.getQueryForDays(43200, 'all'); // 12h interval
 | ||||||
|  |       const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout }); | ||||||
|  |       return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.err('$listAll() error' + (e instanceof Error ? e.message : e)); | ||||||
|  |       return []; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] { |   private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] { | ||||||
|     return statistic.map((s) => { |     return statistic.map((s) => { | ||||||
|       return { |       return { | ||||||
|  | |||||||
| @ -15,10 +15,11 @@ class StatisticsRoutes { | |||||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', this.$getStatisticsByTime.bind(this, '2y')) |       .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', this.$getStatisticsByTime.bind(this, '2y')) | ||||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', this.$getStatisticsByTime.bind(this, '3y')) |       .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', this.$getStatisticsByTime.bind(this, '3y')) | ||||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/4y', this.$getStatisticsByTime.bind(this, '4y')) |       .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/4y', this.$getStatisticsByTime.bind(this, '4y')) | ||||||
|  |       .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/all', this.$getStatisticsByTime.bind(this, 'all')) | ||||||
|     ; |     ; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y', req: Request, res: Response) { |   private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y' | 'all', req: Request, res: Response) { | ||||||
|     res.header('Pragma', 'public'); |     res.header('Pragma', 'public'); | ||||||
|     res.header('Cache-control', 'public'); |     res.header('Cache-control', 'public'); | ||||||
|     res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); |     res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); | ||||||
| @ -26,10 +27,6 @@ class StatisticsRoutes { | |||||||
|     try { |     try { | ||||||
|       let result; |       let result; | ||||||
|       switch (time as string) { |       switch (time as string) { | ||||||
|         case '2h': |  | ||||||
|           result = await statisticsApi.$list2H(); |  | ||||||
|           res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); |  | ||||||
|           break; |  | ||||||
|         case '24h': |         case '24h': | ||||||
|           result = await statisticsApi.$list24H(); |           result = await statisticsApi.$list24H(); | ||||||
|           res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); |           res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||||
| @ -58,8 +55,13 @@ class StatisticsRoutes { | |||||||
|         case '4y': |         case '4y': | ||||||
|           result = await statisticsApi.$list4Y(); |           result = await statisticsApi.$list4Y(); | ||||||
|           break; |           break; | ||||||
|  |         case 'all': | ||||||
|  |           result = await statisticsApi.$listAll(); | ||||||
|  |           break; | ||||||
|         default: |         default: | ||||||
|           result = await statisticsApi.$list2H(); |           result = await statisticsApi.$list2H(); | ||||||
|  |           res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||||
|  |           break; | ||||||
|       } |       } | ||||||
|       res.json(result); |       res.json(result); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|  | |||||||
| @ -22,6 +22,14 @@ import { deepClone } from '../utils/clone'; | |||||||
| import priceUpdater from '../tasks/price-updater'; | import priceUpdater from '../tasks/price-updater'; | ||||||
| import { ApiPrice } from '../repositories/PricesRepository'; | import { ApiPrice } from '../repositories/PricesRepository'; | ||||||
| 
 | 
 | ||||||
|  | // valid 'want' subscriptions
 | ||||||
|  | const wantable = [ | ||||||
|  |   'blocks', | ||||||
|  |   'mempool-blocks', | ||||||
|  |   'live-2h-chart', | ||||||
|  |   'stats', | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
| class WebsocketHandler { | class WebsocketHandler { | ||||||
|   private wss: WebSocket.Server | undefined; |   private wss: WebSocket.Server | undefined; | ||||||
|   private extraInitProperties = {}; |   private extraInitProperties = {}; | ||||||
| @ -30,7 +38,7 @@ class WebsocketHandler { | |||||||
|   private numConnected = 0; |   private numConnected = 0; | ||||||
|   private numDisconnected = 0; |   private numDisconnected = 0; | ||||||
| 
 | 
 | ||||||
|   private initData: { [key: string]: string } = {}; |   private socketData: { [key: string]: string } = {}; | ||||||
|   private serializedInitData: string = '{}'; |   private serializedInitData: string = '{}'; | ||||||
| 
 | 
 | ||||||
|   constructor() { } |   constructor() { } | ||||||
| @ -39,28 +47,28 @@ class WebsocketHandler { | |||||||
|     this.wss = wss; |     this.wss = wss; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   setExtraInitProperties(property: string, value: any) { |   setExtraInitData(property: string, value: any) { | ||||||
|     this.extraInitProperties[property] = value; |     this.extraInitProperties[property] = value; | ||||||
|     this.setInitDataFields(this.extraInitProperties); |     this.updateSocketDataFields(this.extraInitProperties); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private setInitDataFields(data: { [property: string]: any }): void { |   private updateSocketDataFields(data: { [property: string]: any }): void { | ||||||
|     for (const property of Object.keys(data)) { |     for (const property of Object.keys(data)) { | ||||||
|       if (data[property] != null) { |       if (data[property] != null) { | ||||||
|         this.initData[property] = JSON.stringify(data[property]); |         this.socketData[property] = JSON.stringify(data[property]); | ||||||
|       } else { |       } else { | ||||||
|         delete this.initData[property]; |         delete this.socketData[property]; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     this.serializedInitData = '{' |     this.serializedInitData = '{' | ||||||
|       + Object.keys(this.initData).map(key => `"${key}": ${this.initData[key]}`).join(', ') |     + Object.keys(this.socketData).map(key => `"${key}": ${this.socketData[key]}`).join(', ') | ||||||
|       + '}'; |     + '}'; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private updateInitData(): void { |   private updateSocketData(): void { | ||||||
|     const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); |     const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); | ||||||
|     const da = difficultyAdjustment.getDifficultyAdjustment(); |     const da = difficultyAdjustment.getDifficultyAdjustment(); | ||||||
|     this.setInitDataFields({ |     this.updateSocketDataFields({ | ||||||
|       'mempoolInfo': memPool.getMempoolInfo(), |       'mempoolInfo': memPool.getMempoolInfo(), | ||||||
|       'vBytesPerSecond': memPool.getVBytesPerSecond(), |       'vBytesPerSecond': memPool.getVBytesPerSecond(), | ||||||
|       'blocks': _blocks, |       'blocks': _blocks, | ||||||
| @ -94,11 +102,33 @@ class WebsocketHandler { | |||||||
|           const parsedMessage: WebsocketResponse = JSON.parse(message); |           const parsedMessage: WebsocketResponse = JSON.parse(message); | ||||||
|           const response = {}; |           const response = {}; | ||||||
| 
 | 
 | ||||||
|           if (parsedMessage.action === 'want') { |           const wantNow = {}; | ||||||
|             client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1; |           if (parsedMessage && parsedMessage.action === 'want' && Array.isArray(parsedMessage.data)) { | ||||||
|             client['want-mempool-blocks'] = parsedMessage.data.indexOf('mempool-blocks') > -1; |             for (const sub of wantable) { | ||||||
|             client['want-live-2h-chart'] = parsedMessage.data.indexOf('live-2h-chart') > -1; |               const key = `want-${sub}`; | ||||||
|             client['want-stats'] = parsedMessage.data.indexOf('stats') > -1; |               const wants = parsedMessage.data.includes(sub); | ||||||
|  |               if (wants && client['wants'] && !client[key]) { | ||||||
|  |                 wantNow[key] = true; | ||||||
|  |               } | ||||||
|  |               client[key] = wants; | ||||||
|  |             } | ||||||
|  |             client['wants'] = true; | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|  |           // send initial data when a client first starts a subscription
 | ||||||
|  |           if (wantNow['want-blocks'] || (parsedMessage && parsedMessage['refresh-blocks'])) { | ||||||
|  |             response['blocks'] = this.socketData['blocks']; | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|  |           if (wantNow['want-mempool-blocks']) { | ||||||
|  |             response['mempool-blocks'] = this.socketData['mempool-blocks']; | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|  |           if (wantNow['want-stats']) { | ||||||
|  |             response['mempoolInfo'] = this.socketData['mempoolInfo']; | ||||||
|  |             response['vBytesPerSecond'] = this.socketData['vBytesPerSecond']; | ||||||
|  |             response['fees'] = this.socketData['fees']; | ||||||
|  |             response['da'] = this.socketData['da']; | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           if (parsedMessage && parsedMessage['track-tx']) { |           if (parsedMessage && parsedMessage['track-tx']) { | ||||||
| @ -109,21 +139,21 @@ class WebsocketHandler { | |||||||
|               if (parsedMessage['watch-mempool']) { |               if (parsedMessage['watch-mempool']) { | ||||||
|                 const rbfCacheTxid = rbfCache.getReplacedBy(trackTxid); |                 const rbfCacheTxid = rbfCache.getReplacedBy(trackTxid); | ||||||
|                 if (rbfCacheTxid) { |                 if (rbfCacheTxid) { | ||||||
|                   response['txReplaced'] = { |                   response['txReplaced'] = JSON.stringify({ | ||||||
|                     txid: rbfCacheTxid, |                     txid: rbfCacheTxid, | ||||||
|                   }; |                   }); | ||||||
|                   client['track-tx'] = null; |                   client['track-tx'] = null; | ||||||
|                 } else { |                 } else { | ||||||
|                   // It might have appeared before we had the time to start watching for it
 |                   // It might have appeared before we had the time to start watching for it
 | ||||||
|                   const tx = memPool.getMempool()[trackTxid]; |                   const tx = memPool.getMempool()[trackTxid]; | ||||||
|                   if (tx) { |                   if (tx) { | ||||||
|                     if (config.MEMPOOL.BACKEND === 'esplora') { |                     if (config.MEMPOOL.BACKEND === 'esplora') { | ||||||
|                       response['tx'] = tx; |                       response['tx'] = JSON.stringify(tx); | ||||||
|                     } else { |                     } else { | ||||||
|                       // tx.prevout is missing from transactions when in bitcoind mode
 |                       // tx.prevout is missing from transactions when in bitcoind mode
 | ||||||
|                       try { |                       try { | ||||||
|                         const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true); |                         const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true); | ||||||
|                         response['tx'] = fullTx; |                         response['tx'] = JSON.stringify(fullTx); | ||||||
|                       } catch (e) { |                       } catch (e) { | ||||||
|                         logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e)); |                         logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e)); | ||||||
|                       } |                       } | ||||||
| @ -131,7 +161,7 @@ class WebsocketHandler { | |||||||
|                   } else { |                   } else { | ||||||
|                     try { |                     try { | ||||||
|                       const fullTx = await transactionUtils.$getMempoolTransactionExtended(client['track-tx'], true); |                       const fullTx = await transactionUtils.$getMempoolTransactionExtended(client['track-tx'], true); | ||||||
|                       response['tx'] = fullTx; |                       response['tx'] = JSON.stringify(fullTx); | ||||||
|                     } catch (e) { |                     } catch (e) { | ||||||
|                       logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e)); |                       logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e)); | ||||||
|                       client['track-mempool-tx'] = parsedMessage['track-tx']; |                       client['track-mempool-tx'] = parsedMessage['track-tx']; | ||||||
| @ -141,10 +171,10 @@ class WebsocketHandler { | |||||||
|               } |               } | ||||||
|               const tx = memPool.getMempool()[trackTxid]; |               const tx = memPool.getMempool()[trackTxid]; | ||||||
|               if (tx && tx.position) { |               if (tx && tx.position) { | ||||||
|                 response['txPosition'] = { |                 response['txPosition'] = JSON.stringify({ | ||||||
|                   txid: trackTxid, |                   txid: trackTxid, | ||||||
|                   position: tx.position, |                   position: tx.position, | ||||||
|                 }; |                 }); | ||||||
|               } |               } | ||||||
|             } else { |             } else { | ||||||
|               client['track-tx'] = null; |               client['track-tx'] = null; | ||||||
| @ -177,10 +207,10 @@ class WebsocketHandler { | |||||||
|               const index = parsedMessage['track-mempool-block']; |               const index = parsedMessage['track-mempool-block']; | ||||||
|               client['track-mempool-block'] = index; |               client['track-mempool-block'] = index; | ||||||
|               const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); |               const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); | ||||||
|               response['projected-block-transactions'] = { |               response['projected-block-transactions'] = JSON.stringify({ | ||||||
|                 index: index, |                 index: index, | ||||||
|                 blockTransactions: mBlocksWithTransactions[index]?.transactions || [], |                 blockTransactions: mBlocksWithTransactions[index]?.transactions || [], | ||||||
|               }; |               }); | ||||||
|             } else { |             } else { | ||||||
|               client['track-mempool-block'] = null; |               client['track-mempool-block'] = null; | ||||||
|             } |             } | ||||||
| @ -189,23 +219,24 @@ class WebsocketHandler { | |||||||
|           if (parsedMessage && parsedMessage['track-rbf'] !== undefined) { |           if (parsedMessage && parsedMessage['track-rbf'] !== undefined) { | ||||||
|             if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) { |             if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) { | ||||||
|               client['track-rbf'] = parsedMessage['track-rbf']; |               client['track-rbf'] = parsedMessage['track-rbf']; | ||||||
|  |               response['rbfLatest'] = JSON.stringify(rbfCache.getRbfTrees(parsedMessage['track-rbf'] === 'fullRbf')); | ||||||
|             } else { |             } else { | ||||||
|               client['track-rbf'] = false; |               client['track-rbf'] = false; | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           if (parsedMessage.action === 'init') { |           if (parsedMessage.action === 'init') { | ||||||
|             if (!this.initData['blocks']?.length || !this.initData['da']) { |             if (!this.socketData['blocks']?.length || !this.socketData['da']) { | ||||||
|               this.updateInitData(); |               this.updateSocketData(); | ||||||
|             } |             } | ||||||
|             if (!this.initData['blocks']?.length) { |             if (!this.socketData['blocks']?.length) { | ||||||
|               return; |               return; | ||||||
|             } |             } | ||||||
|             client.send(this.serializedInitData); |             client.send(this.serializedInitData); | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           if (parsedMessage.action === 'ping') { |           if (parsedMessage.action === 'ping') { | ||||||
|             response['pong'] = true; |             response['pong'] = JSON.stringify(true); | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           if (parsedMessage['track-donation'] && parsedMessage['track-donation'].length === 22) { |           if (parsedMessage['track-donation'] && parsedMessage['track-donation'].length === 22) { | ||||||
| @ -221,7 +252,8 @@ class WebsocketHandler { | |||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           if (Object.keys(response).length) { |           if (Object.keys(response).length) { | ||||||
|             client.send(JSON.stringify(response)); |             const serializedResponse = this.serializeResponse(response); | ||||||
|  |             client.send(serializedResponse); | ||||||
|           } |           } | ||||||
|         } catch (e) { |         } catch (e) { | ||||||
|           logger.debug('Error parsing websocket message: ' + (e instanceof Error ? e.message : e)); |           logger.debug('Error parsing websocket message: ' + (e instanceof Error ? e.message : e)); | ||||||
| @ -250,7 +282,7 @@ class WebsocketHandler { | |||||||
|       throw new Error('WebSocket.Server is not set'); |       throw new Error('WebSocket.Server is not set'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     this.setInitDataFields({ 'loadingIndicators': indicators }); |     this.updateSocketDataFields({ 'loadingIndicators': indicators }); | ||||||
| 
 | 
 | ||||||
|     const response = JSON.stringify({ loadingIndicators: indicators }); |     const response = JSON.stringify({ loadingIndicators: indicators }); | ||||||
|     this.wss.clients.forEach((client) => { |     this.wss.clients.forEach((client) => { | ||||||
| @ -266,7 +298,7 @@ class WebsocketHandler { | |||||||
|       throw new Error('WebSocket.Server is not set'); |       throw new Error('WebSocket.Server is not set'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     this.setInitDataFields({ 'conversions': conversionRates }); |     this.updateSocketDataFields({ 'conversions': conversionRates }); | ||||||
| 
 | 
 | ||||||
|     const response = JSON.stringify({ conversions: conversionRates }); |     const response = JSON.stringify({ conversions: conversionRates }); | ||||||
|     this.wss.clients.forEach((client) => { |     this.wss.clients.forEach((client) => { | ||||||
| @ -336,11 +368,21 @@ class WebsocketHandler { | |||||||
|     memPool.addToSpendMap(newTransactions); |     memPool.addToSpendMap(newTransactions); | ||||||
|     const recommendedFees = feeApi.getRecommendedFee(); |     const recommendedFees = feeApi.getRecommendedFee(); | ||||||
| 
 | 
 | ||||||
|  |     const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx)); | ||||||
|  | 
 | ||||||
|     // update init data
 |     // update init data
 | ||||||
|     this.updateInitData(); |     this.updateSocketDataFields({ | ||||||
|  |       'mempoolInfo': mempoolInfo, | ||||||
|  |       'vBytesPerSecond': vBytesPerSecond, | ||||||
|  |       'mempool-blocks': mBlocks, | ||||||
|  |       'transactions': latestTransactions, | ||||||
|  |       'loadingIndicators': loadingIndicators.getLoadingIndicators(), | ||||||
|  |       'da': da?.previousTime ? da : undefined, | ||||||
|  |       'fees': recommendedFees, | ||||||
|  |     }); | ||||||
| 
 | 
 | ||||||
|     // cache serialized objects to avoid stringify-ing the same thing for every client
 |     // cache serialized objects to avoid stringify-ing the same thing for every client
 | ||||||
|     const responseCache = { ...this.initData }; |     const responseCache = { ...this.socketData }; | ||||||
|     function getCachedResponse(key: string,  data): string { |     function getCachedResponse(key: string,  data): string { | ||||||
|       if (!responseCache[key]) { |       if (!responseCache[key]) { | ||||||
|         responseCache[key] = JSON.stringify(data); |         responseCache[key] = JSON.stringify(data); | ||||||
| @ -371,8 +413,6 @@ class WebsocketHandler { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx)); |  | ||||||
| 
 |  | ||||||
|     this.wss.clients.forEach(async (client) => { |     this.wss.clients.forEach(async (client) => { | ||||||
|       if (client.readyState !== WebSocket.OPEN) { |       if (client.readyState !== WebSocket.OPEN) { | ||||||
|         return; |         return; | ||||||
| @ -490,7 +530,7 @@ class WebsocketHandler { | |||||||
|         if (rbfReplacedBy) { |         if (rbfReplacedBy) { | ||||||
|           response['rbfTransaction'] = JSON.stringify({ |           response['rbfTransaction'] = JSON.stringify({ | ||||||
|             txid: rbfReplacedBy, |             txid: rbfReplacedBy, | ||||||
|           }) |           }); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         const rbfChange = rbfChanges.map[client['track-tx']]; |         const rbfChange = rbfChanges.map[client['track-tx']]; | ||||||
| @ -524,9 +564,7 @@ class WebsocketHandler { | |||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (Object.keys(response).length) { |       if (Object.keys(response).length) { | ||||||
|         const serializedResponse = '{' |         const serializedResponse = this.serializeResponse(response); | ||||||
|           + Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ') |  | ||||||
|           + '}'; |  | ||||||
|         client.send(serializedResponse); |         client.send(serializedResponse); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
| @ -626,11 +664,19 @@ class WebsocketHandler { | |||||||
| 
 | 
 | ||||||
|     const da = difficultyAdjustment.getDifficultyAdjustment(); |     const da = difficultyAdjustment.getDifficultyAdjustment(); | ||||||
|     const fees = feeApi.getRecommendedFee(); |     const fees = feeApi.getRecommendedFee(); | ||||||
|  |     const mempoolInfo = memPool.getMempoolInfo(); | ||||||
| 
 | 
 | ||||||
|     // update init data
 |     // update init data
 | ||||||
|     this.updateInitData(); |     this.updateSocketDataFields({ | ||||||
|  |       'mempoolInfo': mempoolInfo, | ||||||
|  |       'blocks': [...blocks.getBlocks(), block].slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT), | ||||||
|  |       'mempool-blocks': mBlocks, | ||||||
|  |       'loadingIndicators': loadingIndicators.getLoadingIndicators(), | ||||||
|  |       'da': da?.previousTime ? da : undefined, | ||||||
|  |       'fees': fees, | ||||||
|  |     }); | ||||||
| 
 | 
 | ||||||
|     const responseCache = { ...this.initData }; |     const responseCache = { ...this.socketData }; | ||||||
|     function getCachedResponse(key, data): string { |     function getCachedResponse(key, data): string { | ||||||
|       if (!responseCache[key]) { |       if (!responseCache[key]) { | ||||||
|         responseCache[key] = JSON.stringify(data); |         responseCache[key] = JSON.stringify(data); | ||||||
| @ -638,22 +684,26 @@ class WebsocketHandler { | |||||||
|       return responseCache[key]; |       return responseCache[key]; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const mempoolInfo = memPool.getMempoolInfo(); |  | ||||||
| 
 |  | ||||||
|     this.wss.clients.forEach((client) => { |     this.wss.clients.forEach((client) => { | ||||||
|       if (client.readyState !== WebSocket.OPEN) { |       if (client.readyState !== WebSocket.OPEN) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (!client['want-blocks']) { |       const response = {}; | ||||||
|         return; | 
 | ||||||
|  |       if (client['want-blocks']) { | ||||||
|  |         response['block'] = getCachedResponse('block', block); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       const response = {}; |       if (client['want-stats']) { | ||||||
|       response['block'] = getCachedResponse('block', block); |         response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo); | ||||||
|       response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo); |         response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', memPool.getVBytesPerSecond()); | ||||||
|       response['da'] = getCachedResponse('da', da?.previousTime ? da : undefined); |         response['fees'] = getCachedResponse('fees', fees); | ||||||
|       response['fees'] = getCachedResponse('fees', fees); | 
 | ||||||
|  |         if (da?.previousTime) { | ||||||
|  |           response['da'] = getCachedResponse('da', da); | ||||||
|  |         } | ||||||
|  |       } | ||||||
| 
 | 
 | ||||||
|       if (mBlocks && client['want-mempool-blocks']) { |       if (mBlocks && client['want-mempool-blocks']) { | ||||||
|         response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks); |         response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks); | ||||||
| @ -748,11 +798,19 @@ class WebsocketHandler { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       const serializedResponse = '{' |       if (Object.keys(response).length) { | ||||||
|  |         const serializedResponse = this.serializeResponse(response); | ||||||
|  |         client.send(serializedResponse); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // takes a dictionary of JSON serialized values
 | ||||||
|  |   // and zips it together into a valid JSON object
 | ||||||
|  |   private serializeResponse(response): string { | ||||||
|  |     return '{' | ||||||
|         + Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ') |         + Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ') | ||||||
|         + '}'; |         + '}'; | ||||||
|       client.send(serializedResponse); |  | ||||||
|     }); |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private printLogs(): void { |   private printLogs(): void { | ||||||
|  | |||||||
| @ -30,7 +30,7 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public async query<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket | |   public async query<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket | | ||||||
|     OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]> |     OkPacket[] | ResultSetHeader>(query, params?, connection?: PoolConnection): Promise<[T, FieldPacket[]]> | ||||||
|   { |   { | ||||||
|     this.checkDBFlag(); |     this.checkDBFlag(); | ||||||
|     let hardTimeout; |     let hardTimeout; | ||||||
| @ -45,7 +45,9 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr | |||||||
|           reject(new Error(`DB query failed to return, reject or time out within ${hardTimeout / 1000}s - ${query?.sql?.slice(0, 160) || (typeof(query) === 'string' || query instanceof String ? query?.slice(0, 160) : 'unknown query')}`)); |           reject(new Error(`DB query failed to return, reject or time out within ${hardTimeout / 1000}s - ${query?.sql?.slice(0, 160) || (typeof(query) === 'string' || query instanceof String ? query?.slice(0, 160) : 'unknown query')}`)); | ||||||
|         }, hardTimeout); |         }, hardTimeout); | ||||||
| 
 | 
 | ||||||
|         this.getPool().then(pool => { |         // Use a specific connection if provided, otherwise delegate to the pool
 | ||||||
|  |         const connectionPromise = connection ? Promise.resolve(connection) : this.getPool(); | ||||||
|  |         connectionPromise.then((pool: PoolConnection | Pool) => { | ||||||
|           return pool.query(query, params) as Promise<[T, FieldPacket[]]>; |           return pool.query(query, params) as Promise<[T, FieldPacket[]]>; | ||||||
|         }).then(result => { |         }).then(result => { | ||||||
|           resolve(result); |           resolve(result); | ||||||
| @ -61,6 +63,33 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   public async $atomicQuery<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket | | ||||||
|  |     OkPacket[] | ResultSetHeader>(queries: { query, params }[]): Promise<[T, FieldPacket[]][]> | ||||||
|  |   { | ||||||
|  |     const pool = await this.getPool(); | ||||||
|  |     const connection = await pool.getConnection(); | ||||||
|  |     try { | ||||||
|  |       await connection.beginTransaction(); | ||||||
|  | 
 | ||||||
|  |       const results: [T, FieldPacket[]][]  = []; | ||||||
|  |       for (const query of queries) { | ||||||
|  |         const result = await this.query(query.query, query.params, connection) as [T, FieldPacket[]]; | ||||||
|  |         results.push(result); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       await connection.commit(); | ||||||
|  | 
 | ||||||
|  |       return results; | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.err('Could not complete db transaction, rolling back: ' + (e instanceof Error ? e.message : e)); | ||||||
|  |       connection.rollback(); | ||||||
|  |       connection.release(); | ||||||
|  |       throw e; | ||||||
|  |     } finally { | ||||||
|  |       connection.release(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   public async checkDbConnection() { |   public async checkDbConnection() { | ||||||
|     this.checkDBFlag(); |     this.checkDBFlag(); | ||||||
|     try { |     try { | ||||||
|  | |||||||
| @ -150,7 +150,7 @@ class Server { | |||||||
| 
 | 
 | ||||||
|     if (config.BISQ.ENABLED) { |     if (config.BISQ.ENABLED) { | ||||||
|       bisq.startBisqService(); |       bisq.startBisqService(); | ||||||
|       bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price)); |       bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitData('bsq-price', price)); | ||||||
|       blocks.setNewBlockCallback(bisq.handleNewBitcoinBlock.bind(bisq)); |       blocks.setNewBlockCallback(bisq.handleNewBitcoinBlock.bind(bisq)); | ||||||
|       bisqMarkets.startBisqService(); |       bisqMarkets.startBisqService(); | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -5,52 +5,10 @@ import { Ancestor, CpfpCluster } from '../mempool.interfaces'; | |||||||
| import transactionRepository from '../repositories/TransactionRepository'; | import transactionRepository from '../repositories/TransactionRepository'; | ||||||
| 
 | 
 | ||||||
| class CpfpRepository { | class CpfpRepository { | ||||||
|   public async $saveCluster(clusterRoot: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number): Promise<boolean> { |  | ||||||
|     if (!txs[0]) { |  | ||||||
|       return false; |  | ||||||
|     } |  | ||||||
|     // skip clusters of transactions with the same fees
 |  | ||||||
|     const roundedEffectiveFee = Math.round(effectiveFeePerVsize * 100) / 100; |  | ||||||
|     const equalFee = txs.length > 1 && txs.reduce((acc, tx) => { |  | ||||||
|       return (acc && Math.round(((tx.fee || 0) / (tx.weight / 4)) * 100) / 100 === roundedEffectiveFee); |  | ||||||
|     }, true); |  | ||||||
|     if (equalFee) { |  | ||||||
|       return false; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     try { |  | ||||||
|       const packedTxs = Buffer.from(this.pack(txs)); |  | ||||||
|       await DB.query( |  | ||||||
|         ` |  | ||||||
|           INSERT INTO compact_cpfp_clusters(root, height, txs, fee_rate) |  | ||||||
|           VALUE (UNHEX(?), ?, ?, ?) |  | ||||||
|           ON DUPLICATE KEY UPDATE |  | ||||||
|             height = ?, |  | ||||||
|             txs = ?, |  | ||||||
|             fee_rate = ? |  | ||||||
|         `,
 |  | ||||||
|         [clusterRoot, height, packedTxs, effectiveFeePerVsize, height, packedTxs, effectiveFeePerVsize] |  | ||||||
|       ); |  | ||||||
|       const maxChunk = 10; |  | ||||||
|       let chunkIndex = 0; |  | ||||||
|       while (chunkIndex < txs.length) { |  | ||||||
|         const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk).map(tx => { |  | ||||||
|           return { txid: tx.txid, cluster: clusterRoot }; |  | ||||||
|         }); |  | ||||||
|         await transactionRepository.$batchSetCluster(chunk); |  | ||||||
|         chunkIndex += maxChunk; |  | ||||||
|       } |  | ||||||
|       return true; |  | ||||||
|     } catch (e: any) { |  | ||||||
|       logger.err(`Cannot save cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e)); |  | ||||||
|       throw e; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   public async $batchSaveClusters(clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[]): Promise<boolean> { |   public async $batchSaveClusters(clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[]): Promise<boolean> { | ||||||
|     try { |     try { | ||||||
|       const clusterValues: any[] = []; |       const clusterValues: [string, number, Buffer, number][] = []; | ||||||
|       const txs: any[] = []; |       const txs: { txid: string, cluster: string }[] = []; | ||||||
| 
 | 
 | ||||||
|       for (const cluster of clusters) { |       for (const cluster of clusters) { | ||||||
|         if (cluster.txs?.length) { |         if (cluster.txs?.length) { | ||||||
| @ -76,16 +34,10 @@ class CpfpRepository { | |||||||
|         return false; |         return false; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       const queries: { query, params }[] = []; | ||||||
|  | 
 | ||||||
|       const maxChunk = 100; |       const maxChunk = 100; | ||||||
|       let chunkIndex = 0; |       let chunkIndex = 0; | ||||||
|       // insert transactions in batches of up to 100 rows
 |  | ||||||
|       while (chunkIndex < txs.length) { |  | ||||||
|         const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk); |  | ||||||
|         await transactionRepository.$batchSetCluster(chunk); |  | ||||||
|         chunkIndex += maxChunk; |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       chunkIndex = 0; |  | ||||||
|       // insert clusters in batches of up to 100 rows
 |       // insert clusters in batches of up to 100 rows
 | ||||||
|       while (chunkIndex < clusterValues.length) { |       while (chunkIndex < clusterValues.length) { | ||||||
|         const chunk = clusterValues.slice(chunkIndex, chunkIndex + maxChunk); |         const chunk = clusterValues.slice(chunkIndex, chunkIndex + maxChunk); | ||||||
| @ -97,12 +49,23 @@ class CpfpRepository { | |||||||
|           return (' (UNHEX(?), ?, ?, ?)'); |           return (' (UNHEX(?), ?, ?, ?)'); | ||||||
|         }) + ';'; |         }) + ';'; | ||||||
|         const values = chunk.flat(); |         const values = chunk.flat(); | ||||||
|         await DB.query( |         queries.push({ | ||||||
|           query, |           query, | ||||||
|           values |           params: values, | ||||||
|         ); |         }); | ||||||
|         chunkIndex += maxChunk; |         chunkIndex += maxChunk; | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|  |       chunkIndex = 0; | ||||||
|  |       // insert transactions in batches of up to 100 rows
 | ||||||
|  |       while (chunkIndex < txs.length) { | ||||||
|  |         const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk); | ||||||
|  |         queries.push(transactionRepository.buildBatchSetQuery(chunk)); | ||||||
|  |         chunkIndex += maxChunk; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       await DB.$atomicQuery(queries); | ||||||
|  | 
 | ||||||
|       return true; |       return true; | ||||||
|     } catch (e: any) { |     } catch (e: any) { | ||||||
|       logger.err(`Cannot save cpfp clusters into db. Reason: ` + (e instanceof Error ? e.message : e)); |       logger.err(`Cannot save cpfp clusters into db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||||
| @ -120,8 +83,8 @@ class CpfpRepository { | |||||||
|       [clusterRoot] |       [clusterRoot] | ||||||
|     ); |     ); | ||||||
|     const cluster = clusterRows[0]; |     const cluster = clusterRows[0]; | ||||||
|     cluster.effectiveFeePerVsize = cluster.fee_rate; |  | ||||||
|     if (cluster?.txs) { |     if (cluster?.txs) { | ||||||
|  |       cluster.effectiveFeePerVsize = cluster.fee_rate; | ||||||
|       cluster.txs = this.unpack(cluster.txs); |       cluster.txs = this.unpack(cluster.txs); | ||||||
|       return cluster; |       return cluster; | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -25,9 +25,8 @@ class TransactionRepository { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public async $batchSetCluster(txs): Promise<void> { |   public buildBatchSetQuery(txs: { txid: string, cluster: string }[]): { query, params } { | ||||||
|     try { |     let query = ` | ||||||
|       let query = ` |  | ||||||
|           INSERT IGNORE INTO compact_transactions |           INSERT IGNORE INTO compact_transactions | ||||||
|           ( |           ( | ||||||
|             txid, |             txid, | ||||||
| @ -35,13 +34,22 @@ class TransactionRepository { | |||||||
|           ) |           ) | ||||||
|           VALUES |           VALUES | ||||||
|       `;
 |       `;
 | ||||||
|       query += txs.map(tx => { |     query += txs.map(tx => { | ||||||
|         return (' (UNHEX(?), UNHEX(?))'); |       return (' (UNHEX(?), UNHEX(?))'); | ||||||
|       }) + ';'; |     }) + ';'; | ||||||
|       const values = txs.map(tx => [tx.txid, tx.cluster]).flat(); |     const values = txs.map(tx => [tx.txid, tx.cluster]).flat(); | ||||||
|  |     return { | ||||||
|  |       query, | ||||||
|  |       params: values, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public async $batchSetCluster(txs): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       const query = this.buildBatchSetQuery(txs); | ||||||
|       await DB.query( |       await DB.query( | ||||||
|         query, |         query.query, | ||||||
|         values |         query.params, | ||||||
|       ); |       ); | ||||||
|     } catch (e: any) { |     } catch (e: any) { | ||||||
|       logger.err(`Cannot save cpfp transactions into db. Reason: ` + (e instanceof Error ? e.message : e)); |       logger.err(`Cannot save cpfp transactions into db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||||
|  | |||||||
| @ -173,6 +173,21 @@ | |||||||
|         </svg> |         </svg> | ||||||
|         <span>Exodus</span> |         <span>Exodus</span> | ||||||
|       </a> |       </a> | ||||||
|  |       <a href="https://www.luminex.io" target="_blank" title="Luminex"> | ||||||
|  |         <svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="66.95" height="80" viewBox="0 0 300.43 385" style="padding-top: 10px;"> | ||||||
|  |           <defs> | ||||||
|  |             <style> | ||||||
|  |               .lum-cls-1 { | ||||||
|  |                 fill: #f2ea25; | ||||||
|  |               } | ||||||
|  |             </style> | ||||||
|  |           </defs> | ||||||
|  |           <path class="lum-cls-1" d="m309.02,90.04c0,49.65-38.73,90.04-95.34,90.04s-95.34-40.39-95.34-90.04S153.77,0,213.69,0c56.28,0,95.34,40.39,95.34,90.04Zm-63.56,0c0-20.52-14.23-37.07-31.78-37.07s-31.78,16.55-31.78,37.07,14.23,37.07,31.78,37.07,31.78-16.55,31.78-37.07Z"/> | ||||||
|  |           <path class="lum-cls-1" d="m311.87,372.67h-66.34l-31.84-47.76-31.84,47.76h-66.34l58.38-90.22-53.07-79.61h66.34l26.54,42.46,26.53-42.46h66.34l-53.07,79.61,58.38,90.22Z"/> | ||||||
|  |           <rect class="lum-cls-1" width="60.69" height="372.67"/> | ||||||
|  |         </svg> | ||||||
|  |         <span>Luminex</span> | ||||||
|  |       </a> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| 
 | 
 | ||||||
| @ -205,7 +220,7 @@ | |||||||
|         <img class="image" src="/resources/profile/mynodebtc.png" /> |         <img class="image" src="/resources/profile/mynodebtc.png" /> | ||||||
|         <span>myNode</span> |         <span>myNode</span> | ||||||
|       </a> |       </a> | ||||||
|       <a href="https://github.com/RoninDojo/RoninDojo" target="_blank" title="RoninDojo"> |       <a href="https://code.samourai.io/ronindojo/RoninDojo" target="_blank" title="RoninDojo"> | ||||||
|         <img class="image" src="/resources/profile/ronindojo.png" /> |         <img class="image" src="/resources/profile/ronindojo.png" /> | ||||||
|         <span>RoninDojo</span> |         <span>RoninDojo</span> | ||||||
|       </a> |       </a> | ||||||
|  | |||||||
| @ -1,5 +1,9 @@ | |||||||
| import { OnChanges } from '@angular/core'; | import { OnChanges } from '@angular/core'; | ||||||
| import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core'; | import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core'; | ||||||
|  | import { TransactionStripped } from '../../interfaces/websocket.interface'; | ||||||
|  | import { StateService } from '../../services/state.service'; | ||||||
|  | import { VbytesPipe } from '../../shared/pipes/bytes-pipe/vbytes.pipe'; | ||||||
|  | import { selectPowerOfTen } from '../../bitcoin.utils'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-fee-distribution-graph', |   selector: 'app-fee-distribution-graph', | ||||||
| @ -7,47 +11,121 @@ import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core | |||||||
|   changeDetection: ChangeDetectionStrategy.OnPush, |   changeDetection: ChangeDetectionStrategy.OnPush, | ||||||
| }) | }) | ||||||
| export class FeeDistributionGraphComponent implements OnInit, OnChanges { | export class FeeDistributionGraphComponent implements OnInit, OnChanges { | ||||||
|   @Input() data: any; |   @Input() feeRange: number[]; | ||||||
|  |   @Input() vsize: number; | ||||||
|  |   @Input() transactions: TransactionStripped[]; | ||||||
|   @Input() height: number | string = 210; |   @Input() height: number | string = 210; | ||||||
|   @Input() top: number | string = 20; |   @Input() top: number | string = 20; | ||||||
|   @Input() right: number | string = 22; |   @Input() right: number | string = 22; | ||||||
|   @Input() left: number | string = 30; |   @Input() left: number | string = 30; | ||||||
|  |   @Input() numSamples: number = 200; | ||||||
|  |   @Input() numLabels: number = 10; | ||||||
|  | 
 | ||||||
|  |   simple: boolean = false; | ||||||
|  |   data: number[][]; | ||||||
|  |   labelInterval: number = 50; | ||||||
| 
 | 
 | ||||||
|   mempoolVsizeFeesOptions: any; |   mempoolVsizeFeesOptions: any; | ||||||
|   mempoolVsizeFeesInitOptions = { |   mempoolVsizeFeesInitOptions = { | ||||||
|     renderer: 'svg' |     renderer: 'svg' | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   constructor() { } |   constructor( | ||||||
|  |     private stateService: StateService, | ||||||
|  |     private vbytesPipe: VbytesPipe, | ||||||
|  |   ) { } | ||||||
| 
 | 
 | ||||||
|   ngOnInit() { |   ngOnInit(): void { | ||||||
|     this.mountChart(); |     this.mountChart(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   ngOnChanges() { |   ngOnChanges(): void { | ||||||
|  |     this.simple = !!this.feeRange?.length; | ||||||
|  |     this.prepareChart(); | ||||||
|     this.mountChart(); |     this.mountChart(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   mountChart() { |   prepareChart(): void { | ||||||
|  |     if (this.simple) { | ||||||
|  |       this.data = this.feeRange.map((rate, index) => [index * 10, rate]); | ||||||
|  |       this.labelInterval = 1; | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     this.data = []; | ||||||
|  |     if (!this.transactions?.length) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const samples = []; | ||||||
|  |     const txs = this.transactions.map(tx => { return { vsize: tx.vsize, rate: tx.rate || (tx.fee / tx.vsize) }; }).sort((a, b) => { return b.rate - a.rate; }); | ||||||
|  |     const maxBlockVSize = this.stateService.env.BLOCK_WEIGHT_UNITS / 4; | ||||||
|  |     const sampleInterval = maxBlockVSize / this.numSamples; | ||||||
|  |     let cumVSize = 0; | ||||||
|  |     let sampleIndex = 0; | ||||||
|  |     let nextSample = 0; | ||||||
|  |     let txIndex = 0; | ||||||
|  |     this.labelInterval = this.numSamples / this.numLabels; | ||||||
|  |     while (nextSample <= maxBlockVSize) { | ||||||
|  |       if (txIndex >= txs.length) { | ||||||
|  |         samples.push([(1 - (sampleIndex / this.numSamples)) * 100, 0]); | ||||||
|  |         nextSample += sampleInterval; | ||||||
|  |         sampleIndex++; | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       while (txs[txIndex] && nextSample < cumVSize + txs[txIndex].vsize) { | ||||||
|  |         samples.push([(1 - (sampleIndex / this.numSamples)) * 100, txs[txIndex].rate]); | ||||||
|  |         nextSample += sampleInterval; | ||||||
|  |         sampleIndex++; | ||||||
|  |       } | ||||||
|  |       cumVSize += txs[txIndex].vsize; | ||||||
|  |       txIndex++; | ||||||
|  |     } | ||||||
|  |     this.data = samples.reverse(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   mountChart(): void { | ||||||
|     this.mempoolVsizeFeesOptions = { |     this.mempoolVsizeFeesOptions = { | ||||||
|       grid: { |       grid: { | ||||||
|         height: '210', |         height: '210', | ||||||
|         right: '20', |         right: '20', | ||||||
|         top: '22', |         top: '22', | ||||||
|         left: '30', |         left: '40', | ||||||
|       }, |       }, | ||||||
|       xAxis: { |       xAxis: { | ||||||
|         type: 'category', |         type: 'category', | ||||||
|         boundaryGap: false, |         boundaryGap: false, | ||||||
|  |         name: '% Weight', | ||||||
|  |         nameLocation: 'middle', | ||||||
|  |         nameGap: 0, | ||||||
|  |         nameTextStyle: { | ||||||
|  |           verticalAlign: 'top', | ||||||
|  |           padding: [30, 0, 0, 0], | ||||||
|  |         }, | ||||||
|  |         axisLabel: { | ||||||
|  |           interval: (index: number): boolean => { return index && (index % this.labelInterval === 0); }, | ||||||
|  |           formatter: (value: number): string => { return Number(value).toFixed(0); }, | ||||||
|  |         }, | ||||||
|  |         axisTick: { | ||||||
|  |           interval: (index:number): boolean => { return (index % this.labelInterval === 0); }, | ||||||
|  |         }, | ||||||
|       }, |       }, | ||||||
|       yAxis: { |       yAxis: { | ||||||
|         type: 'value', |         type: 'value', | ||||||
|  |         // name: 'Effective Fee Rate s/vb',
 | ||||||
|  |         // nameLocation: 'middle',
 | ||||||
|         splitLine: { |         splitLine: { | ||||||
|           lineStyle: { |           lineStyle: { | ||||||
|             type: 'dotted', |             type: 'dotted', | ||||||
|             color: '#ffffff66', |             color: '#ffffff66', | ||||||
|             opacity: 0.25, |             opacity: 0.25, | ||||||
|           } |           } | ||||||
|  |         }, | ||||||
|  |         axisLabel: { | ||||||
|  |           formatter: (value: number): string => { | ||||||
|  |             const selectedPowerOfTen = selectPowerOfTen(value); | ||||||
|  |             const newVal = Math.round(value / selectedPowerOfTen.divider); | ||||||
|  |             return `${newVal}${selectedPowerOfTen.unit}`; | ||||||
|  |           }, | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|       series: [{ |       series: [{ | ||||||
| @ -58,14 +136,18 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges { | |||||||
|           position: 'top', |           position: 'top', | ||||||
|           color: '#ffffff', |           color: '#ffffff', | ||||||
|           textShadowBlur: 0, |           textShadowBlur: 0, | ||||||
|           formatter: (label: any) => { |           formatter: (label: { data: number[] }): string => { | ||||||
|             return Math.floor(label.data); |             const value = label.data[1]; | ||||||
|  |             const selectedPowerOfTen = selectPowerOfTen(value); | ||||||
|  |             const newVal = Math.round(value / selectedPowerOfTen.divider); | ||||||
|  |             return `${newVal}${selectedPowerOfTen.unit}`; | ||||||
|           }, |           }, | ||||||
|         }, |         }, | ||||||
|  |         showAllSymbol: false, | ||||||
|         smooth: true, |         smooth: true, | ||||||
|         lineStyle: { |         lineStyle: { | ||||||
|           color: '#D81B60', |           color: '#D81B60', | ||||||
|           width: 4, |           width: 1, | ||||||
|         }, |         }, | ||||||
|         itemStyle: { |         itemStyle: { | ||||||
|           color: '#b71c1c', |           color: '#b71c1c', | ||||||
|  | |||||||
| @ -39,11 +39,11 @@ | |||||||
|             </tr> |             </tr> | ||||||
|           </tbody> |           </tbody> | ||||||
|         </table> |         </table> | ||||||
|         <app-fee-distribution-graph *ngIf="webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph> |         <app-fee-distribution-graph *ngIf="webGlEnabled" [transactions]="mempoolBlockTransactions$ | async" [feeRange]="mempoolBlock.isStack ? mempoolBlock.feeRange : []" [vsize]="mempoolBlock.blockVSize" ></app-fee-distribution-graph> | ||||||
|       </div> |       </div> | ||||||
|       <div class="col-md chart-container"> |       <div class="col-md chart-container"> | ||||||
|         <app-mempool-block-overview *ngIf="webGlEnabled" [index]="mempoolBlockIndex" (txPreviewEvent)="setTxPreview($event)"></app-mempool-block-overview> |         <app-mempool-block-overview *ngIf="webGlEnabled" [index]="mempoolBlockIndex" (txPreviewEvent)="setTxPreview($event)"></app-mempool-block-overview> | ||||||
|         <app-fee-distribution-graph *ngIf="!webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph> |         <app-fee-distribution-graph *ngIf="!webGlEnabled" [transactions]="mempoolBlockTransactions$ | async" [feeRange]="mempoolBlock.isStack ? mempoolBlock.feeRange : []" [vsize]="mempoolBlock.blockVSize" ></app-fee-distribution-graph> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|  | |||||||
| @ -17,6 +17,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { | |||||||
|   network$: Observable<string>; |   network$: Observable<string>; | ||||||
|   mempoolBlockIndex: number; |   mempoolBlockIndex: number; | ||||||
|   mempoolBlock$: Observable<MempoolBlock>; |   mempoolBlock$: Observable<MempoolBlock>; | ||||||
|  |   mempoolBlockTransactions$: Observable<TransactionStripped[]>; | ||||||
|   ordinal$: BehaviorSubject<string> = new BehaviorSubject(''); |   ordinal$: BehaviorSubject<string> = new BehaviorSubject(''); | ||||||
|   previewTx: TransactionStripped | void; |   previewTx: TransactionStripped | void; | ||||||
|   webGlEnabled: boolean; |   webGlEnabled: boolean; | ||||||
| @ -53,6 +54,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { | |||||||
|                 const ordinal = this.getOrdinal(mempoolBlocks[this.mempoolBlockIndex]); |                 const ordinal = this.getOrdinal(mempoolBlocks[this.mempoolBlockIndex]); | ||||||
|                 this.ordinal$.next(ordinal); |                 this.ordinal$.next(ordinal); | ||||||
|                 this.seoService.setTitle(ordinal); |                 this.seoService.setTitle(ordinal); | ||||||
|  |                 mempoolBlocks[this.mempoolBlockIndex].isStack = mempoolBlocks[this.mempoolBlockIndex].blockVSize > this.stateService.blockVSize; | ||||||
|                 return mempoolBlocks[this.mempoolBlockIndex]; |                 return mempoolBlocks[this.mempoolBlockIndex]; | ||||||
|               }) |               }) | ||||||
|             ); |             ); | ||||||
| @ -62,6 +64,8 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { | |||||||
|         }) |         }) | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|  |     this.mempoolBlockTransactions$ = this.stateService.liveMempoolBlockTransactions$.pipe(map(txMap => Object.values(txMap))); | ||||||
|  | 
 | ||||||
|     this.network$ = this.stateService.networkChanged$; |     this.network$ = this.stateService.networkChanged$; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -143,6 +143,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|           this.mempoolBlocksFull = JSON.parse(stringifiedBlocks); |           this.mempoolBlocksFull = JSON.parse(stringifiedBlocks); | ||||||
|           this.mempoolBlocks = this.reduceMempoolBlocksToFitScreen(JSON.parse(stringifiedBlocks)); |           this.mempoolBlocks = this.reduceMempoolBlocksToFitScreen(JSON.parse(stringifiedBlocks)); | ||||||
| 
 | 
 | ||||||
|  |           this.now = Date.now(); | ||||||
|  | 
 | ||||||
|           this.updateMempoolBlockStyles(); |           this.updateMempoolBlockStyles(); | ||||||
|           this.calculateTransactionPosition(); |           this.calculateTransactionPosition(); | ||||||
|           return this.mempoolBlocks; |           return this.mempoolBlocks; | ||||||
| @ -152,7 +154,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|     this.difficultyAdjustments$ = this.stateService.difficultyAdjustment$ |     this.difficultyAdjustments$ = this.stateService.difficultyAdjustment$ | ||||||
|       .pipe( |       .pipe( | ||||||
|         map((da) => { |         map((da) => { | ||||||
|           this.now = new Date().getTime(); |           this.now = Date.now(); | ||||||
|  |           this.cd.markForCheck(); | ||||||
|           return da; |           return da; | ||||||
|         }) |         }) | ||||||
|       ); |       ); | ||||||
|  | |||||||
| @ -20,39 +20,46 @@ | |||||||
|                 <fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon> |                 <fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon> | ||||||
|               </a> |               </a> | ||||||
|             </div> |             </div> | ||||||
|             <div class="btn-group btn-group-toggle" name="radioBasic"> |             <div class="btn-toggle-rows" name="radioBasic"> | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2h'"> |               <div class="btn-group btn-group-toggle"> | ||||||
|                 <input type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h" formControlName="dateSpan"> 2H |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2h'"> | ||||||
|                 (LIVE) |                   <input type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h" formControlName="dateSpan"> 2H | ||||||
|               </label> |                   (LIVE) | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '24h'"> |                 </label> | ||||||
|                 <input type="radio" [value]="'24h'" [routerLink]="['/graphs' | relativeUrl]" fragment="24h" formControlName="dateSpan"> |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '24h'"> | ||||||
|                 24H |                   <input type="radio" [value]="'24h'" [routerLink]="['/graphs' | relativeUrl]" fragment="24h" formControlName="dateSpan"> | ||||||
|               </label> |                   24H | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1w'"> |                 </label> | ||||||
|                 <input type="radio" [value]="'1w'" [routerLink]="['/graphs' | relativeUrl]" fragment="1w" formControlName="dateSpan"> 1W |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1w'"> | ||||||
|               </label> |                   <input type="radio" [value]="'1w'" [routerLink]="['/graphs' | relativeUrl]" fragment="1w" formControlName="dateSpan"> 1W | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1m'"> |                 </label> | ||||||
|                 <input type="radio" [value]="'1m'" [routerLink]="['/graphs' | relativeUrl]" fragment="1m" formControlName="dateSpan"> 1M |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1m'"> | ||||||
|               </label> |                   <input type="radio" [value]="'1m'" [routerLink]="['/graphs' | relativeUrl]" fragment="1m" formControlName="dateSpan"> 1M | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3m'"> |                 </label> | ||||||
|                 <input type="radio" [value]="'3m'" [routerLink]="['/graphs' | relativeUrl]" fragment="3m" formControlName="dateSpan"> 3M |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3m'"> | ||||||
|               </label> |                   <input type="radio" [value]="'3m'" [routerLink]="['/graphs' | relativeUrl]" fragment="3m" formControlName="dateSpan"> 3M | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '6m'"> |                 </label> | ||||||
|                 <input type="radio" [value]="'6m'" [routerLink]="['/graphs' | relativeUrl]" fragment="6m" formControlName="dateSpan"> 6M |               </div> | ||||||
|               </label> |               <div class="btn-group btn-group-toggle"> | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '6m'"> | ||||||
|                 <input type="radio" [value]="'1y'" [routerLink]="['/graphs' | relativeUrl]" fragment="1y" formControlName="dateSpan"> 1Y |                   <input type="radio" [value]="'6m'" [routerLink]="['/graphs' | relativeUrl]" fragment="6m" formControlName="dateSpan"> 6M | ||||||
|               </label> |                 </label> | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1y'"> | ||||||
|                 <input type="radio" [value]="'2y'" [routerLink]="['/graphs' | relativeUrl]" fragment="2y" formControlName="dateSpan"> 2Y |                   <input type="radio" [value]="'1y'" [routerLink]="['/graphs' | relativeUrl]" fragment="1y" formControlName="dateSpan"> 1Y | ||||||
|               </label> |                 </label> | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2y'"> | ||||||
|                 <input type="radio" [value]="'3y'" [routerLink]="['/graphs' | relativeUrl]" fragment="3y" formControlName="dateSpan"> 3Y |                   <input type="radio" [value]="'2y'" [routerLink]="['/graphs' | relativeUrl]" fragment="2y" formControlName="dateSpan"> 2Y | ||||||
|               </label> |                 </label> | ||||||
|               <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '4y'"> |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3y'"> | ||||||
|                 <input type="radio" [value]="'4y'" [routerLink]="['/graphs' | relativeUrl]" fragment="4y" formControlName="dateSpan"> 4Y |                   <input type="radio" [value]="'3y'" [routerLink]="['/graphs' | relativeUrl]" fragment="3y" formControlName="dateSpan"> 3Y | ||||||
|               </label> |                 </label> | ||||||
|  |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '4y'"> | ||||||
|  |                   <input type="radio" [value]="'4y'" [routerLink]="['/graphs' | relativeUrl]" fragment="4y" formControlName="dateSpan"> 4Y | ||||||
|  |                 </label> | ||||||
|  |                 <label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'"> | ||||||
|  |                   <input type="radio" [value]="'all'" [routerLink]="['/graphs' | relativeUrl]" fragment="all" formControlName="dateSpan"><span i18n="all">All</span> | ||||||
|  |                 </label> | ||||||
|  |               </div> | ||||||
|             </div> |             </div> | ||||||
|             <div class="small-buttons"> |             <div class="small-buttons"> | ||||||
|               <div ngbDropdown #myDrop="ngbDropdown"> |               <div ngbDropdown #myDrop="ngbDropdown"> | ||||||
|  | |||||||
| @ -53,17 +53,17 @@ | |||||||
|   } |   } | ||||||
| } | } | ||||||
| .formRadioGroup.mining { | .formRadioGroup.mining { | ||||||
|   @media (min-width: 991px) { |   @media (min-width: 1035px) { | ||||||
|     position: relative; |     position: relative; | ||||||
|     top: -100px; |     top: -100px; | ||||||
|   } |   } | ||||||
|   @media (min-width: 830px) and (max-width: 991px) { |   @media (min-width: 830px) and (max-width: 1035px) { | ||||||
|     position: relative; |     position: relative; | ||||||
|     top: 0px; |     top: 0px; | ||||||
|   } |   } | ||||||
| } | } | ||||||
| .formRadioGroup.no-menu { | .formRadioGroup.no-menu { | ||||||
|   @media (min-width: 991px) { |   @media (min-width: 1035px) { | ||||||
|     position: relative; |     position: relative; | ||||||
|     top: -33px; |     top: -33px; | ||||||
|   } |   } | ||||||
| @ -183,3 +183,43 @@ | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .btn-toggle-rows { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: row; | ||||||
|  |   align-items: stretch; | ||||||
|  |   justify-content: stretch; | ||||||
|  | 
 | ||||||
|  |   .btn-group { | ||||||
|  |     flex-grow: 1; | ||||||
|  |     flex-shrink: 1; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @media (min-width: 500px) { | ||||||
|  |     .btn-group:first-child > .btn:last-child { | ||||||
|  |       border-top-right-radius: 0; | ||||||
|  |       border-bottom-right-radius: 0; | ||||||
|  |     } | ||||||
|  |     .btn-group:last-child > .btn:first-child { | ||||||
|  |       border-top-left-radius: 0; | ||||||
|  |       border-bottom-left-radius: 0; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @media (max-width: 499px) { | ||||||
|  |     flex-direction: column; | ||||||
|  | 
 | ||||||
|  |     .btn-group:first-child > .btn:first-child { | ||||||
|  |       border-bottom-left-radius: 0; | ||||||
|  |     } | ||||||
|  |     .btn-group:first-child > .btn:last-child { | ||||||
|  |       border-bottom-right-radius: 0; | ||||||
|  |     } | ||||||
|  |     .btn-group:last-child > .btn:first-child { | ||||||
|  |       border-top-left-radius: 0; | ||||||
|  |     } | ||||||
|  |     .btn-group:last-child > .btn:last-child { | ||||||
|  |       border-top-right-radius: 0; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -72,8 +72,10 @@ export class StatisticsComponent implements OnInit { | |||||||
|     this.route |     this.route | ||||||
|       .fragment |       .fragment | ||||||
|       .subscribe((fragment) => { |       .subscribe((fragment) => { | ||||||
|         if (['2h', '24h', '1w', '1m', '3m', '6m', '1y', '2y', '3y', '4y'].indexOf(fragment) > -1) { |         if (['2h', '24h', '1w', '1m', '3m', '6m', '1y', '2y', '3y', '4y', 'all'].indexOf(fragment) > -1) { | ||||||
|           this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); |           this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); | ||||||
|  |         } else { | ||||||
|  |           this.radioGroupForm.controls.dateSpan.setValue('2h', { emitEvent: false }); | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
| @ -114,7 +116,12 @@ export class StatisticsComponent implements OnInit { | |||||||
|         if (this.radioGroupForm.controls.dateSpan.value === '3y') { |         if (this.radioGroupForm.controls.dateSpan.value === '3y') { | ||||||
|           return this.apiService.list3YStatistics$(); |           return this.apiService.list3YStatistics$(); | ||||||
|         } |         } | ||||||
|         return this.apiService.list4YStatistics$(); |         if (this.radioGroupForm.controls.dateSpan.value === '4y') { | ||||||
|  |           return this.apiService.list4YStatistics$(); | ||||||
|  |         } | ||||||
|  |         if (this.radioGroupForm.controls.dateSpan.value === 'all') { | ||||||
|  |           return this.apiService.listAllTimeStatistics$(); | ||||||
|  |         } | ||||||
|       }) |       }) | ||||||
|     ) |     ) | ||||||
|     .subscribe((mempoolStats: any) => { |     .subscribe((mempoolStats: any) => { | ||||||
|  | |||||||
| @ -105,7 +105,7 @@ | |||||||
|                           <app-time kind="until" [time]="(60 * 1000 * this.mempoolPosition.block) + now" [fastRender]="false" [fixedRender]="true"></app-time> |                           <app-time kind="until" [time]="(60 * 1000 * this.mempoolPosition.block) + now" [fastRender]="false" [fixedRender]="true"></app-time> | ||||||
|                         </ng-template> |                         </ng-template> | ||||||
|                         <ng-template #timeEstimateDefault> |                         <ng-template #timeEstimateDefault> | ||||||
|                           <app-time kind="until" *ngIf="(timeAvg$ | async) as timeAvg;" [time]="(timeAvg * this.mempoolPosition.block) + now + timeAvg" [fastRender]="false" [fixedRender]="true"></app-time> |                           <app-time kind="until" *ngIf="(da$ | async) as da;" [time]="da.timeAvg * (this.mempoolPosition.block + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true"></app-time> | ||||||
|                         </ng-template> |                         </ng-template> | ||||||
|                       </ng-template> |                       </ng-template> | ||||||
|                     </ng-template> |                     </ng-template> | ||||||
|  | |||||||
| @ -19,7 +19,7 @@ import { WebsocketService } from '../../services/websocket.service'; | |||||||
| import { AudioService } from '../../services/audio.service'; | import { AudioService } from '../../services/audio.service'; | ||||||
| import { ApiService } from '../../services/api.service'; | import { ApiService } from '../../services/api.service'; | ||||||
| import { SeoService } from '../../services/seo.service'; | import { SeoService } from '../../services/seo.service'; | ||||||
| import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition } from '../../interfaces/node-api.interface'; | import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment } from '../../interfaces/node-api.interface'; | ||||||
| import { LiquidUnblinding } from './liquid-ublinding'; | import { LiquidUnblinding } from './liquid-ublinding'; | ||||||
| import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | ||||||
| import { Price, PriceService } from '../../services/price.service'; | import { Price, PriceService } from '../../services/price.service'; | ||||||
| @ -65,7 +65,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|   fetchCachedTx$ = new Subject<string>(); |   fetchCachedTx$ = new Subject<string>(); | ||||||
|   isCached: boolean = false; |   isCached: boolean = false; | ||||||
|   now = Date.now(); |   now = Date.now(); | ||||||
|   timeAvg$: Observable<number>; |   da$: Observable<DifficultyAdjustment>; | ||||||
|   liquidUnblinding = new LiquidUnblinding(); |   liquidUnblinding = new LiquidUnblinding(); | ||||||
|   inputIndex: number; |   inputIndex: number; | ||||||
|   outputIndex: number; |   outputIndex: number; | ||||||
| @ -117,11 +117,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|       this.setFlowEnabled(); |       this.setFlowEnabled(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     this.timeAvg$ = timer(0, 1000) |     this.da$ = this.stateService.difficultyAdjustment$.pipe( | ||||||
|       .pipe( |       tap(() => { | ||||||
|         switchMap(() => this.stateService.difficultyAdjustment$), |         this.now = Date.now(); | ||||||
|         map((da) => da.timeAvg) |       }) | ||||||
|       ); |     ); | ||||||
| 
 | 
 | ||||||
|     this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { |     this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { | ||||||
|       this.fragmentParams = new URLSearchParams(fragment || ''); |       this.fragmentParams = new URLSearchParams(fragment || ''); | ||||||
| @ -236,6 +236,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { |     this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { | ||||||
|  |       this.now = Date.now(); | ||||||
|       if (txPosition && txPosition.txid === this.txId && txPosition.position) { |       if (txPosition && txPosition.txid === this.txId && txPosition.position) { | ||||||
|         this.mempoolPosition = txPosition.position; |         this.mempoolPosition = txPosition.position; | ||||||
|         if (this.tx && !this.tx.status.confirmed) { |         if (this.tx && !this.tx.status.confirmed) { | ||||||
| @ -434,12 +435,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe((mempoolBlocks) => { |     this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe((mempoolBlocks) => { | ||||||
|  |       this.now = Date.now(); | ||||||
|  | 
 | ||||||
|       if (!this.tx || this.mempoolPosition) { |       if (!this.tx || this.mempoolPosition) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       this.now = Date.now(); |  | ||||||
| 
 |  | ||||||
|       const txFeePerVSize = |       const txFeePerVSize = | ||||||
|         this.tx.effectiveFeePerVsize || this.tx.fee / (this.tx.weight / 4); |         this.tx.effectiveFeePerVsize || this.tx.fee / (this.tx.weight / 4); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -31,6 +31,7 @@ export interface WebsocketResponse { | |||||||
|   'track-rbf'?: string; |   'track-rbf'?: string; | ||||||
|   'watch-mempool'?: boolean; |   'watch-mempool'?: boolean; | ||||||
|   'track-bisq-market'?: string; |   'track-bisq-market'?: string; | ||||||
|  |   'refresh-blocks'?: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface ReplacedTransaction extends Transaction { | export interface ReplacedTransaction extends Transaction { | ||||||
|  | |||||||
| @ -72,6 +72,10 @@ export class ApiService { | |||||||
|     return this.httpClient.get<OptimizedMempoolStats[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/statistics/4y'); |     return this.httpClient.get<OptimizedMempoolStats[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/statistics/4y'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   listAllTimeStatistics$(): Observable<OptimizedMempoolStats[]> { | ||||||
|  |     return this.httpClient.get<OptimizedMempoolStats[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/statistics/all'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getTransactionTimes$(txIds: string[]): Observable<number[]> { |   getTransactionTimes$(txIds: string[]): Observable<number[]> { | ||||||
|     let params = new HttpParams(); |     let params = new HttpParams(); | ||||||
|     txIds.forEach((txId: string) => { |     txIds.forEach((txId: string) => { | ||||||
|  | |||||||
| @ -1,11 +1,11 @@ | |||||||
| import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; | import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; | ||||||
| import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; | import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable, merge } from 'rxjs'; | ||||||
| import { Transaction } from '../interfaces/electrs.interface'; | import { Transaction } from '../interfaces/electrs.interface'; | ||||||
| import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; | import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; | ||||||
| import { BlockExtended, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; | import { BlockExtended, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; | ||||||
| import { Router, NavigationStart } from '@angular/router'; | import { Router, NavigationStart } from '@angular/router'; | ||||||
| import { isPlatformBrowser } from '@angular/common'; | import { isPlatformBrowser } from '@angular/common'; | ||||||
| import { map, shareReplay } from 'rxjs/operators'; | import { map, scan, shareReplay, tap } from 'rxjs/operators'; | ||||||
| import { StorageService } from './storage.service'; | import { StorageService } from './storage.service'; | ||||||
| 
 | 
 | ||||||
| interface MarkBlockState { | interface MarkBlockState { | ||||||
| @ -100,6 +100,7 @@ export class StateService { | |||||||
|   mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1); |   mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1); | ||||||
|   mempoolBlockTransactions$ = new Subject<TransactionStripped[]>(); |   mempoolBlockTransactions$ = new Subject<TransactionStripped[]>(); | ||||||
|   mempoolBlockDelta$ = new Subject<MempoolBlockDelta>(); |   mempoolBlockDelta$ = new Subject<MempoolBlockDelta>(); | ||||||
|  |   liveMempoolBlockTransactions$: Observable<{ [txid: string]: TransactionStripped}>; | ||||||
|   txReplaced$ = new Subject<ReplacedTransaction>(); |   txReplaced$ = new Subject<ReplacedTransaction>(); | ||||||
|   txRbfInfo$ = new Subject<RbfTree>(); |   txRbfInfo$ = new Subject<RbfTree>(); | ||||||
|   rbfLatest$ = new Subject<RbfTree[]>(); |   rbfLatest$ = new Subject<RbfTree[]>(); | ||||||
| @ -166,6 +167,30 @@ export class StateService { | |||||||
| 
 | 
 | ||||||
|     this.blocks$ = new ReplaySubject<[BlockExtended, string]>(this.env.KEEP_BLOCKS_AMOUNT); |     this.blocks$ = new ReplaySubject<[BlockExtended, string]>(this.env.KEEP_BLOCKS_AMOUNT); | ||||||
| 
 | 
 | ||||||
|  |     this.liveMempoolBlockTransactions$ = merge( | ||||||
|  |       this.mempoolBlockTransactions$.pipe(map(transactions => { return { transactions }; })), | ||||||
|  |       this.mempoolBlockDelta$.pipe(map(delta => { return { delta }; })), | ||||||
|  |     ).pipe(scan((transactions: { [txid: string]: TransactionStripped }, change: any): { [txid: string]: TransactionStripped } => { | ||||||
|  |       if (change.transactions) { | ||||||
|  |         const txMap = {} | ||||||
|  |         change.transactions.forEach(tx => { | ||||||
|  |           txMap[tx.txid] = tx; | ||||||
|  |         }) | ||||||
|  |         return txMap; | ||||||
|  |       } else { | ||||||
|  |         change.delta.changed.forEach(tx => { | ||||||
|  |           transactions[tx.txid].rate = tx.rate; | ||||||
|  |         }) | ||||||
|  |         change.delta.removed.forEach(txid => { | ||||||
|  |           delete transactions[txid]; | ||||||
|  |         }); | ||||||
|  |         change.delta.added.forEach(tx => { | ||||||
|  |           transactions[tx.txid] = tx; | ||||||
|  |         }); | ||||||
|  |         return transactions; | ||||||
|  |       } | ||||||
|  |     }, {})); | ||||||
|  | 
 | ||||||
|     if (this.env.BASE_MODULE === 'bisq') { |     if (this.env.BASE_MODULE === 'bisq') { | ||||||
|       this.network = this.env.BASE_MODULE; |       this.network = this.env.BASE_MODULE; | ||||||
|       this.networkChanged$.next(this.env.BASE_MODULE); |       this.networkChanged$.next(this.env.BASE_MODULE); | ||||||
|  | |||||||
| @ -235,6 +235,8 @@ export class WebsocketService { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleResponse(response: WebsocketResponse) { |   handleResponse(response: WebsocketResponse) { | ||||||
|  |     let reinitBlocks = false; | ||||||
|  | 
 | ||||||
|     if (response.blocks && response.blocks.length) { |     if (response.blocks && response.blocks.length) { | ||||||
|       const blocks = response.blocks; |       const blocks = response.blocks; | ||||||
|       let maxHeight = 0; |       let maxHeight = 0; | ||||||
| @ -256,9 +258,11 @@ export class WebsocketService { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (response.block) { |     if (response.block) { | ||||||
|       if (response.block.height > this.stateService.latestBlockHeight) { |       if (response.block.height === this.stateService.latestBlockHeight + 1) { | ||||||
|         this.stateService.updateChainTip(response.block.height); |         this.stateService.updateChainTip(response.block.height); | ||||||
|         this.stateService.blocks$.next([response.block, response.txConfirmed || '']); |         this.stateService.blocks$.next([response.block, response.txConfirmed || '']); | ||||||
|  |       } else if (response.block.height > this.stateService.latestBlockHeight + 1) { | ||||||
|  |         reinitBlocks = true; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (response.txConfirmed) { |       if (response.txConfirmed) { | ||||||
| @ -369,5 +373,9 @@ export class WebsocketService { | |||||||
|     if (response['git-commit']) { |     if (response['git-commit']) { | ||||||
|       this.stateService.backendInfo$.next(response['git-commit']); |       this.stateService.backendInfo$.next(response['git-commit']); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     if (reinitBlocks) { | ||||||
|  |       this.websocketSubject.next({'refresh-blocks': true}); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -500,7 +500,7 @@ html:lang(ru) .card-title { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .fee-distribution-chart { | .fee-distribution-chart { | ||||||
|   height: 250px; |   height: 265px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .fees-wrapper-tooltip-chart { | .fees-wrapper-tooltip-chart { | ||||||
|  | |||||||
| @ -21,6 +21,7 @@ do for url in / \ | |||||||
| 	'/api/v1/statistics/2y' \ | 	'/api/v1/statistics/2y' \ | ||||||
| 	'/api/v1/statistics/3y' \ | 	'/api/v1/statistics/3y' \ | ||||||
| 	'/api/v1/statistics/4y' \ | 	'/api/v1/statistics/4y' \ | ||||||
|  | 	'/api/v1/statistics/all' \ | ||||||
| 	'/api/v1/mining/pools/24h' \ | 	'/api/v1/mining/pools/24h' \ | ||||||
| 	'/api/v1/mining/pools/3d' \ | 	'/api/v1/mining/pools/3d' \ | ||||||
| 	'/api/v1/mining/pools/1w' \ | 	'/api/v1/mining/pools/1w' \ | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user