Merge branch 'master' into mononaut/redis-error-handling
This commit is contained in:
		
						commit
						086ae6978a
					
				
							
								
								
									
										36
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										36
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @ -115,6 +115,10 @@ jobs: | ||||
| 
 | ||||
|       - name: Sync-assets | ||||
|         run: npm run sync-assets-dev | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|           MEMPOOL_CDN: 1 | ||||
|           VERBOSE: 1 | ||||
|         working-directory: assets/frontend | ||||
| 
 | ||||
|       - name: Zip mining-pool assets | ||||
| @ -237,6 +241,8 @@ jobs: | ||||
|         working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend | ||||
|         env:  | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|           MEMPOOL_CDN: 1 | ||||
|           VERBOSE: 1 | ||||
|    | ||||
|   e2e: | ||||
|     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" | ||||
| @ -329,4 +335,32 @@ jobs: | ||||
|           CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|           CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} | ||||
|          | ||||
| 
 | ||||
|   validate_docker_json: | ||||
|     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" | ||||
|     runs-on: "ubuntu-latest" | ||||
|     name: Validate generated backend Docker JSON | ||||
| 
 | ||||
|     steps:  | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           path: docker | ||||
|        | ||||
|       - name: Install jq | ||||
|         run: sudo apt-get install jq -y | ||||
| 
 | ||||
|       - name: Create new start script to run on CI | ||||
|         run: | | ||||
|           sed '$d' start.sh > start_ci.sh | ||||
|         working-directory: docker/docker/backend | ||||
| 
 | ||||
|       - name: Run the script to generate the sample JSON | ||||
|         run: | | ||||
|           sh start_ci.sh | ||||
|         working-directory: docker/docker/backend | ||||
| 
 | ||||
|       - name: Validate JSON syntax | ||||
|         run: | | ||||
|           cat mempool-config.json | jq | ||||
|         working-directory: docker/docker/backend | ||||
|  | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -6,3 +6,4 @@ backend/mempool-config.json | ||||
| frontend/src/resources/config.template.js | ||||
| frontend/src/resources/config.js | ||||
| target | ||||
| docker/backend/start_ci.sh | ||||
							
								
								
									
										6
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -7,6 +7,12 @@ mempool-config.json | ||||
| pools.json | ||||
| icons.json | ||||
| 
 | ||||
| # docker | ||||
| Dockerfile | ||||
| GeoIP | ||||
| start.sh | ||||
| wait-for-it.sh | ||||
| 
 | ||||
| # compiled output | ||||
| /dist | ||||
| /tmp | ||||
|  | ||||
							
								
								
									
										26
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										26
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -17,7 +17,7 @@ | ||||
|         "crypto-js": "~4.2.0", | ||||
|         "express": "~4.18.2", | ||||
|         "maxmind": "~4.3.11", | ||||
|         "mysql2": "~3.7.0", | ||||
|         "mysql2": "~3.9.1", | ||||
|         "redis": "^4.6.6", | ||||
|         "rust-gbt": "file:./rust-gbt", | ||||
|         "socks-proxy-agent": "~7.0.0", | ||||
| @ -3673,9 +3673,9 @@ | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/follow-redirects": { | ||||
|       "version": "1.15.2", | ||||
|       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", | ||||
|       "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", | ||||
|       "version": "1.15.5", | ||||
|       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", | ||||
|       "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", | ||||
|       "funding": [ | ||||
|         { | ||||
|           "type": "individual", | ||||
| @ -6110,9 +6110,9 @@ | ||||
|       "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" | ||||
|     }, | ||||
|     "node_modules/mysql2": { | ||||
|       "version": "3.7.0", | ||||
|       "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.7.0.tgz", | ||||
|       "integrity": "sha512-c45jA3Jc1X8yJKzrWu1GpplBKGwv/wIV6ITZTlCSY7npF2YfJR+6nMP5e+NTQhUeJPSyOQAbGDCGEHbAl8HN9w==", | ||||
|       "version": "3.9.1", | ||||
|       "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz", | ||||
|       "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==", | ||||
|       "dependencies": { | ||||
|         "denque": "^2.1.0", | ||||
|         "generate-function": "^2.3.1", | ||||
| @ -10440,9 +10440,9 @@ | ||||
|       "dev": true | ||||
|     }, | ||||
|     "follow-redirects": { | ||||
|       "version": "1.15.2", | ||||
|       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", | ||||
|       "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" | ||||
|       "version": "1.15.5", | ||||
|       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", | ||||
|       "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==" | ||||
|     }, | ||||
|     "form-data": { | ||||
|       "version": "4.0.0", | ||||
| @ -12230,9 +12230,9 @@ | ||||
|       "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" | ||||
|     }, | ||||
|     "mysql2": { | ||||
|       "version": "3.7.0", | ||||
|       "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.7.0.tgz", | ||||
|       "integrity": "sha512-c45jA3Jc1X8yJKzrWu1GpplBKGwv/wIV6ITZTlCSY7npF2YfJR+6nMP5e+NTQhUeJPSyOQAbGDCGEHbAl8HN9w==", | ||||
|       "version": "3.9.1", | ||||
|       "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz", | ||||
|       "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==", | ||||
|       "requires": { | ||||
|         "denque": "^2.1.0", | ||||
|         "generate-function": "^2.3.1", | ||||
|  | ||||
| @ -47,7 +47,7 @@ | ||||
|     "crypto-js": "~4.2.0", | ||||
|     "express": "~4.18.2", | ||||
|     "maxmind": "~4.3.11", | ||||
|     "mysql2": "~3.7.0", | ||||
|     "mysql2": "~3.9.1", | ||||
|     "rust-gbt": "file:./rust-gbt", | ||||
|     "redis": "^4.6.6", | ||||
|     "socks-proxy-agent": "~7.0.0", | ||||
|  | ||||
| @ -11,9 +11,35 @@ describe('Mempool Difficulty Adjustment', () => { | ||||
|     }; | ||||
| 
 | ||||
|     const vectors = [ | ||||
|       [ // Vector 1
 | ||||
|       [ // Vector 1 (normal adjustment)
 | ||||
|         [ // Inputs
 | ||||
|           dt('2024-02-02T15:42:06.000Z'), // Last DA time (in seconds)
 | ||||
|           dt('2024-02-08T14:43:05.000Z'), // timestamp of 504 blocks ago (in seconds)
 | ||||
|           dt('2024-02-11T22:43:01.000Z'), // Current time (now) (in seconds)
 | ||||
|           830027,                         // Current block height
 | ||||
|           7.333505241141637,             // Previous retarget % (Passed through)
 | ||||
|           'mainnet',                      // Network (if testnet, next value is non-zero)
 | ||||
|           0,                              // Latest block timestamp in seconds (only used if difficulty already locked in)
 | ||||
|         ], | ||||
|         { // Expected Result
 | ||||
|           progressPercent: 71.97420634920636, | ||||
|           difficultyChange: 8.512745140778843, | ||||
|           estimatedRetargetDate: 1708004001715, | ||||
|           remainingBlocks: 565, | ||||
|           remainingTime: 312620715, | ||||
|           previousRetarget: 7.333505241141637, | ||||
|           previousTime: 1706888526, | ||||
|           nextRetargetHeight: 830592, | ||||
|           timeAvg: 553311, | ||||
|           adjustedTimeAvg: 553311, | ||||
|           timeOffset: 0, | ||||
|           expectedBlocks: 1338.0916666666667, | ||||
|         }, | ||||
|       ], | ||||
|       [ // Vector 2 (within quarter-epoch overlap)
 | ||||
|         [ // Inputs
 | ||||
|           dt('2022-08-18T11:07:00.000Z'), // Last DA time (in seconds)
 | ||||
|           dt('2022-08-16T03:16:54.000Z'), // timestamp of 504 blocks ago (in seconds)
 | ||||
|           dt('2022-08-19T14:03:53.000Z'), // Current time (now) (in seconds)
 | ||||
|           750134,                         // Current block height
 | ||||
|           0.6280047707459726,             // Previous retarget % (Passed through)
 | ||||
| @ -22,21 +48,23 @@ describe('Mempool Difficulty Adjustment', () => { | ||||
|         ], | ||||
|         { // Expected Result
 | ||||
|           progressPercent: 9.027777777777777, | ||||
|           difficultyChange: 13.180707740199772, | ||||
|           estimatedRetargetDate: 1661895424692, | ||||
|           difficultyChange: 1.0420538959004633, | ||||
|           estimatedRetargetDate: 1662009048328, | ||||
|           remainingBlocks: 1834, | ||||
|           remainingTime: 977591692, | ||||
|           remainingTime: 1091215328, | ||||
|           previousRetarget: 0.6280047707459726, | ||||
|           previousTime: 1660820820, | ||||
|           nextRetargetHeight: 751968, | ||||
|           timeAvg: 533038, | ||||
|           adjustedTimeAvg: 594992, | ||||
|           timeOffset: 0, | ||||
|           expectedBlocks: 161.68833333333333, | ||||
|         }, | ||||
|       ], | ||||
|       [ // Vector 2 (testnet)
 | ||||
|       [ // Vector 3 (testnet)
 | ||||
|         [ // Inputs
 | ||||
|           dt('2022-08-18T11:07:00.000Z'), // Last DA time (in seconds)
 | ||||
|           dt('2022-08-16T03:16:54.000Z'), // timestamp of 504 blocks ago (in seconds)
 | ||||
|           dt('2022-08-19T14:03:53.000Z'), // Current time (now) (in seconds)
 | ||||
|           750134,                         // Current block height
 | ||||
|           0.6280047707459726,             // Previous retarget % (Passed through)
 | ||||
| @ -45,22 +73,24 @@ describe('Mempool Difficulty Adjustment', () => { | ||||
|         ], | ||||
|         { // Expected Result is same other than timeOffset
 | ||||
|           progressPercent: 9.027777777777777, | ||||
|           difficultyChange: 13.180707740199772, | ||||
|           estimatedRetargetDate: 1661895424692, | ||||
|           difficultyChange: 1.0420538959004633, | ||||
|           estimatedRetargetDate: 1662009048328, | ||||
|           remainingBlocks: 1834, | ||||
|           remainingTime: 977591692, | ||||
|           remainingTime: 1091215328, | ||||
|           previousTime: 1660820820, | ||||
|           previousRetarget: 0.6280047707459726, | ||||
|           nextRetargetHeight: 751968, | ||||
|           timeAvg: 533038, | ||||
|           adjustedTimeAvg: 594992, | ||||
|           timeOffset: -667000, // 11 min 7 seconds since last block (testnet only)
 | ||||
|           // If we add time avg to abs(timeOffset) it makes exactly 1200000 ms, or 20 minutes
 | ||||
|           expectedBlocks: 161.68833333333333, | ||||
|         }, | ||||
|       ], | ||||
|       [ // Vector 3 (mainnet lock-in (epoch ending 788255))
 | ||||
|       [ // Vector 4 (mainnet lock-in (epoch ending 788255))
 | ||||
|         [ // Inputs
 | ||||
|           dt('2023-04-20T09:57:33.000Z'), // Last DA time (in seconds)
 | ||||
|           dt('2022-08-16T03:16:54.000Z'), // timestamp of 504 blocks ago (in seconds)
 | ||||
|           dt('2023-05-04T14:54:09.000Z'), // Current time (now) (in seconds)
 | ||||
|           788255,                         // Current block height
 | ||||
|           1.7220298879531821,             // Previous retarget % (Passed through)
 | ||||
| @ -77,16 +107,17 @@ describe('Mempool Difficulty Adjustment', () => { | ||||
|           previousTime: 1681984653, | ||||
|           nextRetargetHeight: 788256, | ||||
|           timeAvg: 609129, | ||||
|           adjustedTimeAvg: 609129, | ||||
|           timeOffset: 0, | ||||
|           expectedBlocks: 2045.66, | ||||
|         }, | ||||
|       ], | ||||
|     ] as [[number, number, number, number, string, number], DifficultyAdjustment][]; | ||||
|     ] as [[number, number, number, number, number, string, number], DifficultyAdjustment][]; | ||||
| 
 | ||||
|     for (const vector of vectors) { | ||||
|       const result = calcDifficultyAdjustment(...vector[0]); | ||||
|       // previousRetarget is passed through untouched
 | ||||
|       expect(result.previousRetarget).toStrictEqual(vector[0][3]); | ||||
|       expect(result.previousRetarget).toStrictEqual(vector[0][4]); | ||||
|       expect(result).toStrictEqual(vector[1]); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
| @ -646,7 +646,7 @@ class BisqMarketsApi { | ||||
|         case 'year': | ||||
|             return strtotime('midnight first day of january', ts); | ||||
|         default: | ||||
|             throw new Error('Unsupported interval: ' + interval); | ||||
|             throw new Error('Unsupported interval'); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -106,6 +106,7 @@ export namespace IBitcoinApi { | ||||
|       address?: string;              //  (string) bitcoin address
 | ||||
|       addresses?: string[];           //  (string) bitcoin addresses
 | ||||
|       pegout_chain?: string;         //  (string) Elements peg-out chain
 | ||||
|       pegout_address?: string;       //  (string) Elements peg-out address
 | ||||
|       pegout_addresses?: string[];   //  (string) Elements peg-out addresses
 | ||||
|     }; | ||||
|   } | ||||
|  | ||||
| @ -4,6 +4,7 @@ import http from 'http'; | ||||
| import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; | ||||
| import { IEsploraApi } from './esplora-api.interface'; | ||||
| import logger from '../../logger'; | ||||
| import { Common } from '../common'; | ||||
| 
 | ||||
| interface FailoverHost { | ||||
|   host: string, | ||||
| @ -15,11 +16,13 @@ interface FailoverHost { | ||||
|   outOfSync?: boolean, | ||||
|   unreachable?: boolean, | ||||
|   preferred?: boolean, | ||||
|   checked: boolean, | ||||
| } | ||||
| 
 | ||||
| class FailoverRouter { | ||||
|   activeHost: FailoverHost; | ||||
|   fallbackHost: FailoverHost; | ||||
|   maxHeight: number = 0; | ||||
|   hosts: FailoverHost[]; | ||||
|   multihost: boolean; | ||||
|   pollInterval: number = 60000; | ||||
| @ -34,6 +37,7 @@ class FailoverRouter { | ||||
|     this.hosts = (config.ESPLORA.FALLBACK || []).map(domain => { | ||||
|       return { | ||||
|         host: domain, | ||||
|         checked: false, | ||||
|         rtts: [], | ||||
|         rtt: Infinity, | ||||
|         failures: 0, | ||||
| @ -46,6 +50,7 @@ class FailoverRouter { | ||||
|       failures: 0, | ||||
|       socket: !!config.ESPLORA.UNIX_SOCKET_PATH, | ||||
|       preferred: true, | ||||
|       checked: false, | ||||
|     }; | ||||
|     this.fallbackHost = this.activeHost; | ||||
|     this.hosts.unshift(this.activeHost); | ||||
| @ -74,66 +79,87 @@ class FailoverRouter { | ||||
|       clearTimeout(this.pollTimer); | ||||
|     } | ||||
| 
 | ||||
|     const results = await Promise.allSettled(this.hosts.map(async (host) => { | ||||
|       if (host.socket) { | ||||
|         return this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: config.ESPLORA.FALLBACK_TIMEOUT }); | ||||
|       } else { | ||||
|         return this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: config.ESPLORA.FALLBACK_TIMEOUT }); | ||||
|       } | ||||
|     })); | ||||
|     const maxHeight = results.reduce((max, result) => Math.max(max, result.status === 'fulfilled' ? result.value?.data || 0 : 0), 0); | ||||
|     const start = Date.now(); | ||||
| 
 | ||||
|     // update rtts & sync status
 | ||||
|     for (let i = 0; i < results.length; i++) { | ||||
|       const host = this.hosts[i]; | ||||
|       const result = results[i].status === 'fulfilled' ? (results[i] as PromiseFulfilledResult<AxiosResponse<number, any>>).value : null; | ||||
|       if (result) { | ||||
|         const height = result.data; | ||||
|         const rtt = result.config['meta'].rtt; | ||||
|         host.rtts.unshift(rtt); | ||||
|         host.rtts.slice(0, 5); | ||||
|         host.rtt = host.rtts.reduce((acc, l) => acc + l, 0) / host.rtts.length; | ||||
|         host.latestHeight = height; | ||||
|         if (height == null || isNaN(height) || (maxHeight - height > 2)) { | ||||
|           host.outOfSync = true; | ||||
|     for (const host of this.hosts) { | ||||
|       try { | ||||
|         const result = await (host.socket | ||||
|           ? this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: config.ESPLORA.FALLBACK_TIMEOUT }) | ||||
|           : this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: config.ESPLORA.FALLBACK_TIMEOUT }) | ||||
|         ); | ||||
|         if (result) { | ||||
|           const height = result.data; | ||||
|           this.maxHeight = Math.max(height, this.maxHeight); | ||||
|           const rtt = result.config['meta'].rtt; | ||||
|           host.rtts.unshift(rtt); | ||||
|           host.rtts.slice(0, 5); | ||||
|           host.rtt = host.rtts.reduce((acc, l) => acc + l, 0) / host.rtts.length; | ||||
|           host.latestHeight = height; | ||||
|           if (height == null || isNaN(height) || (this.maxHeight - height > 2)) { | ||||
|             host.outOfSync = true; | ||||
|           } else { | ||||
|             host.outOfSync = false; | ||||
|           } | ||||
|           host.unreachable = false; | ||||
|         } else { | ||||
|           host.outOfSync = false; | ||||
|           host.outOfSync = true; | ||||
|           host.unreachable = true; | ||||
|           host.rtts = []; | ||||
|           host.rtt = Infinity; | ||||
|         } | ||||
|         host.unreachable = false; | ||||
|       } else { | ||||
|       } catch (e) { | ||||
|         host.outOfSync = true; | ||||
|         host.unreachable = true; | ||||
|         host.rtts = []; | ||||
|         host.rtt = Infinity; | ||||
|       } | ||||
|       host.checked = true; | ||||
|        | ||||
| 
 | ||||
|       // switch if the current host is out of sync or significantly slower than the next best alternative
 | ||||
|       const rankOrder = this.sortHosts(); | ||||
|       // switch if the current host is out of sync or significantly slower than the next best alternative
 | ||||
|       if (this.activeHost.outOfSync || this.activeHost.unreachable || (this.activeHost !== rankOrder[0] && rankOrder[0].preferred) || (!this.activeHost.preferred && this.activeHost.rtt > (rankOrder[0].rtt * 2) + 50)) { | ||||
|         if (this.activeHost.unreachable) { | ||||
|           logger.warn(`🚨🚨🚨 Unable to reach ${this.activeHost.host}, failing over to next best alternative 🚨🚨🚨`); | ||||
|         } else if (this.activeHost.outOfSync) { | ||||
|           logger.warn(`🚨🚨🚨 ${this.activeHost.host} has fallen behind, failing over to next best alternative 🚨🚨🚨`); | ||||
|         } else { | ||||
|           logger.debug(`🛠️ ${this.activeHost.host} is no longer the best esplora host 🛠️`); | ||||
|         } | ||||
|         this.electHost(); | ||||
|       } | ||||
|       await Common.sleep$(50); | ||||
|     } | ||||
| 
 | ||||
|     this.sortHosts(); | ||||
|     const rankOrder = this.updateFallback(); | ||||
|     logger.debug(`Tomahawk ranking:\n${rankOrder.map((host, index) => this.formatRanking(index, host, this.activeHost, this.maxHeight)).join('\n')}`); | ||||
| 
 | ||||
|     logger.debug(`Tomahawk ranking:\n${this.hosts.map((host, index) => this.formatRanking(index, host, this.activeHost, maxHeight)).join('\n')}`); | ||||
|     const elapsed = Date.now() - start; | ||||
| 
 | ||||
|     // switch if the current host is out of sync or significantly slower than the next best alternative
 | ||||
|     if (this.activeHost.outOfSync || this.activeHost.unreachable || (this.activeHost !== this.hosts[0] && this.hosts[0].preferred) || (!this.activeHost.preferred && this.activeHost.rtt > (this.hosts[0].rtt * 2) + 50)) { | ||||
|       if (this.activeHost.unreachable) { | ||||
|         logger.warn(`🚨🚨🚨 Unable to reach ${this.activeHost.host}, failing over to next best alternative 🚨🚨🚨`); | ||||
|       } else if (this.activeHost.outOfSync) { | ||||
|         logger.warn(`🚨🚨🚨 ${this.activeHost.host} has fallen behind, failing over to next best alternative 🚨🚨🚨`); | ||||
|       } else { | ||||
|         logger.debug(`🛠️ ${this.activeHost.host} is no longer the best esplora host 🛠️`); | ||||
|       } | ||||
|       this.electHost(); | ||||
|     } | ||||
| 
 | ||||
|     this.pollTimer = setTimeout(() => { this.pollHosts(); }, this.pollInterval); | ||||
|     this.pollTimer = setTimeout(() => { this.pollHosts(); }, Math.max(1, this.pollInterval - elapsed)); | ||||
|   } | ||||
| 
 | ||||
|   private formatRanking(index: number, host: FailoverHost, active: FailoverHost, maxHeight: number): string { | ||||
|     const heightStatus = host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < maxHeight ? '🟧' : '✅'); | ||||
|     return `${host === active ? '⭐️' : '  '} ${host.rtt < Infinity ? Math.round(host.rtt).toString().padStart(5, ' ') + 'ms' : '    -  '} ${host.unreachable ? '🔥' : '✅'} | block: ${host.latestHeight || '??????'} ${heightStatus} | ${host.host} ${host === active ? '⭐️' : '  '}`; | ||||
|     const heightStatus = !host.checked ? '⏳' : (host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < maxHeight ? '🟧' : '✅')); | ||||
|     return `${host === active ? '⭐️' : '  '} ${host.rtt < Infinity ? Math.round(host.rtt).toString().padStart(5, ' ') + 'ms' : '    -  '} ${!host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅')} | block: ${host.latestHeight || '??????'} ${heightStatus} | ${host.host} ${host === active ? '⭐️' : '  '}`; | ||||
|   } | ||||
| 
 | ||||
|   private updateFallback(): FailoverHost[] { | ||||
|     const rankOrder = this.sortHosts(); | ||||
|     if (rankOrder.length > 1 && rankOrder[0] === this.activeHost) { | ||||
|       this.fallbackHost = rankOrder[1]; | ||||
|     } else { | ||||
|       this.fallbackHost = rankOrder[0]; | ||||
|     } | ||||
|     return rankOrder; | ||||
|   } | ||||
| 
 | ||||
|   // sort hosts by connection quality, and update default fallback
 | ||||
|   private sortHosts(): void { | ||||
|   private sortHosts(): FailoverHost[] { | ||||
|     // sort by connection quality
 | ||||
|     this.hosts.sort((a, b) => { | ||||
|     return this.hosts.slice().sort((a, b) => { | ||||
|       if ((a.unreachable || a.outOfSync) === (b.unreachable || b.outOfSync)) { | ||||
|         if  (a.preferred === b.preferred) { | ||||
|           // lower rtt is best
 | ||||
| @ -145,19 +171,14 @@ class FailoverRouter { | ||||
|         return (a.unreachable || a.outOfSync) ? 1 : -1; | ||||
|       } | ||||
|     }); | ||||
|     if (this.hosts.length > 1 && this.hosts[0] === this.activeHost) { | ||||
|       this.fallbackHost = this.hosts[1]; | ||||
|     } else { | ||||
|       this.fallbackHost = this.hosts[0]; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // depose the active host and choose the next best replacement
 | ||||
|   private electHost(): void { | ||||
|     this.activeHost.outOfSync = true; | ||||
|     this.activeHost.failures = 0; | ||||
|     this.sortHosts(); | ||||
|     this.activeHost = this.hosts[0]; | ||||
|     const rankOrder = this.sortHosts(); | ||||
|     this.activeHost = rankOrder[0]; | ||||
|     logger.warn(`Switching esplora host to ${this.activeHost.host}`); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -2,7 +2,7 @@ import config from '../config'; | ||||
| import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; | ||||
| import logger from '../logger'; | ||||
| import memPool from './mempool'; | ||||
| import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified } from '../mempool.interfaces'; | ||||
| import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified, BlockAudit } from '../mempool.interfaces'; | ||||
| import { Common } from './common'; | ||||
| import diskCache from './disk-cache'; | ||||
| import transactionUtils from './transaction-utils'; | ||||
| @ -37,8 +37,10 @@ class Blocks { | ||||
|   private currentBits = 0; | ||||
|   private lastDifficultyAdjustmentTime = 0; | ||||
|   private previousDifficultyRetarget = 0; | ||||
|   private quarterEpochBlockTime: number | null = null; | ||||
|   private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; | ||||
|   private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[]) => Promise<void>)[] = []; | ||||
|   private classifyingBlocks: boolean = false; | ||||
| 
 | ||||
|   private mainLoopTimeout: number = 120000; | ||||
| 
 | ||||
| @ -451,7 +453,9 @@ class Blocks { | ||||
|         if (config.MEMPOOL.BACKEND === 'esplora') { | ||||
|           const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendTransaction(tx)); | ||||
|           const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs); | ||||
|           await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary
 | ||||
|           if (cpfpSummary) { | ||||
|             await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary
 | ||||
|           } | ||||
|         } else { | ||||
|           await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary
 | ||||
|         } | ||||
| @ -565,6 +569,11 @@ class Blocks { | ||||
|    * [INDEXING] Index transaction classification flags for Goggles | ||||
|    */ | ||||
|   public async $classifyBlocks(): Promise<void> { | ||||
|     if (this.classifyingBlocks) { | ||||
|       return; | ||||
|     } | ||||
|     this.classifyingBlocks = true; | ||||
| 
 | ||||
|     // classification requires an esplora backend
 | ||||
|     if (!Common.gogglesIndexingEnabled() || config.MEMPOOL.BACKEND !== 'esplora') { | ||||
|       return; | ||||
| @ -676,6 +685,8 @@ class Blocks { | ||||
|         indexedThisRun = 0; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     this.classifyingBlocks = false; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -773,6 +784,16 @@ class Blocks { | ||||
|     } else { | ||||
|       this.currentBlockHeight = this.blocks[this.blocks.length - 1].height; | ||||
|     } | ||||
|     if (this.currentBlockHeight >= 503) { | ||||
|       try { | ||||
|         const quarterEpochBlockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight - 503); | ||||
|         const quarterEpochBlock = await bitcoinApi.$getBlock(quarterEpochBlockHash); | ||||
|         this.quarterEpochBlockTime = quarterEpochBlock?.timestamp; | ||||
|       } catch (e) { | ||||
|         this.quarterEpochBlockTime = null; | ||||
|         logger.warn('failed to update last epoch block time: ' + (e instanceof Error ? e.message : e)); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (blockHeightTip - this.currentBlockHeight > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 2) { | ||||
|       logger.info(`${blockHeightTip - this.currentBlockHeight} blocks since tip. Fast forwarding to the ${config.MEMPOOL.INITIAL_BLOCKS_AMOUNT} recent blocks`); | ||||
| @ -995,11 +1016,11 @@ class Blocks { | ||||
|     return state; | ||||
|   } | ||||
| 
 | ||||
|   private updateTimerProgress(state, msg) { | ||||
|   private updateTimerProgress(state, msg): void { | ||||
|     state.progress = msg; | ||||
|   } | ||||
| 
 | ||||
|   private clearTimer(state) { | ||||
|   private clearTimer(state): void { | ||||
|     if (state.timer) { | ||||
|       clearTimeout(state.timer); | ||||
|     } | ||||
| @ -1088,13 +1109,19 @@ class Blocks { | ||||
|       summary = { | ||||
|         id: hash, | ||||
|         transactions: cpfpSummary.transactions.map(tx => { | ||||
|           let flags: number = 0; | ||||
|           try { | ||||
|             flags = tx.flags || Common.getTransactionFlags(tx); | ||||
|           } catch (e) { | ||||
|             logger.warn('Failed to classify transaction: ' + (e instanceof Error ? e.message : e)); | ||||
|           } | ||||
|           return { | ||||
|             txid: tx.txid, | ||||
|             fee: tx.fee || 0, | ||||
|             vsize: tx.vsize, | ||||
|             value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)), | ||||
|             rate: tx.effectiveFeePerVsize, | ||||
|             flags: tx.flags || Common.getTransactionFlags(tx), | ||||
|             flags: flags, | ||||
|           }; | ||||
|         }), | ||||
|       }; | ||||
| @ -1284,7 +1311,7 @@ class Blocks { | ||||
|     return blocks; | ||||
|   } | ||||
| 
 | ||||
|   public async $getBlockAuditSummary(hash: string): Promise<any> { | ||||
|   public async $getBlockAuditSummary(hash: string): Promise<BlockAudit | null> { | ||||
|     if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { | ||||
|       return BlocksAuditsRepository.$getBlockAudit(hash); | ||||
|     } else { | ||||
| @ -1300,11 +1327,15 @@ class Blocks { | ||||
|     return this.previousDifficultyRetarget; | ||||
|   } | ||||
| 
 | ||||
|   public getQuarterEpochBlockTime(): number | null { | ||||
|     return this.quarterEpochBlockTime; | ||||
|   } | ||||
| 
 | ||||
|   public getCurrentBlockHeight(): number { | ||||
|     return this.currentBlockHeight; | ||||
|   } | ||||
| 
 | ||||
|   public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise<CpfpSummary> { | ||||
|   public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise<CpfpSummary | null> { | ||||
|     let transactions = txs; | ||||
|     if (!transactions) { | ||||
|       if (config.MEMPOOL.BACKEND === 'esplora') { | ||||
| @ -1319,14 +1350,19 @@ class Blocks { | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]); | ||||
|     if (transactions?.length != null) { | ||||
|       const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]); | ||||
| 
 | ||||
|     await this.$saveCpfp(hash, height, summary); | ||||
|       await this.$saveCpfp(hash, height, summary); | ||||
| 
 | ||||
|     const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions); | ||||
|     await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats); | ||||
|       const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions); | ||||
|       await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats); | ||||
| 
 | ||||
|     return summary; | ||||
|       return summary; | ||||
|     } else { | ||||
|       logger.err(`Cannot index CPFP for block ${height} - missing transaction data`); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> { | ||||
|  | ||||
| @ -6,6 +6,7 @@ import { NodeSocket } from '../repositories/NodesSocketsRepository'; | ||||
| import { isIP } from 'net'; | ||||
| import transactionUtils from './transaction-utils'; | ||||
| import { isPoint } from '../utils/secp256k1'; | ||||
| import logger from '../logger'; | ||||
| export class Common { | ||||
|   static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ? | ||||
|     '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49' | ||||
| @ -245,7 +246,8 @@ export class Common { | ||||
|     } else if (tx.version === 2) { | ||||
|       flags |= TransactionFlags.v2; | ||||
|     } | ||||
|     const reusedAddresses: { [address: string ]: number } = {}; | ||||
|     const reusedInputAddresses: { [address: string ]: number } = {}; | ||||
|     const reusedOutputAddresses: { [address: string ]: number } = {}; | ||||
|     const inValues = {}; | ||||
|     const outValues = {}; | ||||
|     let rbf = false; | ||||
| @ -261,6 +263,9 @@ export class Common { | ||||
|         case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break; | ||||
|         case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break; | ||||
|         case 'v1_p2tr': { | ||||
|           if (!vin.witness?.length) { | ||||
|             throw new Error('Taproot input missing witness data'); | ||||
|           } | ||||
|           flags |= TransactionFlags.p2tr; | ||||
|           // in taproot, if the last witness item begins with 0x50, it's an annex
 | ||||
|           const hasAnnex = vin.witness?.[vin.witness.length - 1].startsWith('50'); | ||||
| @ -286,7 +291,7 @@ export class Common { | ||||
|       } | ||||
| 
 | ||||
|       if (vin.prevout?.scriptpubkey_address) { | ||||
|         reusedAddresses[vin.prevout?.scriptpubkey_address] = (reusedAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1; | ||||
|         reusedInputAddresses[vin.prevout?.scriptpubkey_address] = (reusedInputAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1; | ||||
|       } | ||||
|       inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1; | ||||
|     } | ||||
| @ -301,7 +306,7 @@ export class Common { | ||||
|         case 'p2pk': { | ||||
|           flags |= TransactionFlags.p2pk; | ||||
|           // detect fake pubkey (i.e. not a valid DER point on the secp256k1 curve)
 | ||||
|           hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey.slice(2, -2)); | ||||
|           hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey?.slice(2, -2)); | ||||
|         } break; | ||||
|         case 'multisig': { | ||||
|           flags |= TransactionFlags.p2ms; | ||||
| @ -321,7 +326,7 @@ export class Common { | ||||
|         case 'op_return': flags |= TransactionFlags.op_return; break; | ||||
|       } | ||||
|       if (vout.scriptpubkey_address) { | ||||
|         reusedAddresses[vout.scriptpubkey_address] = (reusedAddresses[vout.scriptpubkey_address] || 0) + 1; | ||||
|         reusedOutputAddresses[vout.scriptpubkey_address] = (reusedOutputAddresses[vout.scriptpubkey_address] || 0) + 1; | ||||
|       } | ||||
|       outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1; | ||||
|     } | ||||
| @ -331,7 +336,7 @@ export class Common { | ||||
|      | ||||
|     // fast but bad heuristic to detect possible coinjoins
 | ||||
|     // (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse)
 | ||||
|     const addressReuse = Object.values(reusedAddresses).reduce((acc, count) => Math.max(acc, count), 0) > 1; | ||||
|     const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1; | ||||
|     if (!addressReuse && tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) { | ||||
|       flags |= TransactionFlags.coinjoin; | ||||
|     } | ||||
| @ -348,7 +353,12 @@ export class Common { | ||||
|   } | ||||
| 
 | ||||
|   static classifyTransaction(tx: TransactionExtended): TransactionClassified { | ||||
|     const flags = Common.getTransactionFlags(tx); | ||||
|     let flags = 0; | ||||
|     try { | ||||
|       flags = Common.getTransactionFlags(tx); | ||||
|     } catch (e) { | ||||
|       logger.warn('Failed to add classification flags to transaction: ' + (e instanceof Error ? e.message : e)); | ||||
|     } | ||||
|     tx.flags = flags; | ||||
|     return { | ||||
|       ...Common.stripTransaction(tx), | ||||
|  | ||||
| @ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; | ||||
| import { RowDataPacket } from 'mysql2'; | ||||
| 
 | ||||
| class DatabaseMigration { | ||||
|   private static currentVersion = 67; | ||||
|   private static currentVersion = 68; | ||||
|   private queryTimeout = 3600_000; | ||||
|   private statisticsAddedIndexed = false; | ||||
|   private uniqueLogs: string[] = []; | ||||
| @ -566,6 +566,20 @@ class DatabaseMigration { | ||||
|       await this.$executeQuery('ALTER TABLE `blocks_templates` ADD INDEX `version` (`version`)'); | ||||
|       await this.updateToSchemaVersion(67); | ||||
|     } | ||||
|      | ||||
|     if (databaseSchemaVersion < 68 && config.MEMPOOL.NETWORK === "liquid") { | ||||
|       await this.$executeQuery('TRUNCATE TABLE elements_pegs'); | ||||
|       await this.$executeQuery('ALTER TABLE elements_pegs ADD PRIMARY KEY (txid, txindex);'); | ||||
|       await this.$executeQuery(`UPDATE state SET number = 0 WHERE name = 'last_elements_block';`); | ||||
|       // Create the federation_addresses table and add the two Liquid Federation change addresses in
 | ||||
|       await this.$executeQuery(this.getCreateFederationAddressesTableQuery(), await this.$checkIfTableExists('federation_addresses')); | ||||
|       await this.$executeQuery(`INSERT INTO federation_addresses (bitcoinaddress) VALUES ('bc1qxvay4an52gcghxq5lavact7r6qe9l4laedsazz8fj2ee2cy47tlqff4aj4')`); // Federation change address
 | ||||
|       await this.$executeQuery(`INSERT INTO federation_addresses (bitcoinaddress) VALUES ('3EiAcrzq1cELXScc98KeCswGWZaPGceT1d')`); // Federation change address
 | ||||
|       // Create the federation_txos table that uses the federation_addresses table as a foreign key
 | ||||
|       await this.$executeQuery(this.getCreateFederationTxosTableQuery(), await this.$checkIfTableExists('federation_txos')); | ||||
|       await this.$executeQuery(`INSERT INTO state VALUES('last_bitcoin_block_audit', 0, NULL);`); | ||||
|       await this.updateToSchemaVersion(68); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -813,6 +827,32 @@ class DatabaseMigration { | ||||
|     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | ||||
|   } | ||||
| 
 | ||||
|   private getCreateFederationAddressesTableQuery(): string { | ||||
|     return `CREATE TABLE IF NOT EXISTS federation_addresses (
 | ||||
|       bitcoinaddress varchar(100) NOT NULL, | ||||
|       PRIMARY KEY (bitcoinaddress) | ||||
|     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | ||||
|   } | ||||
| 
 | ||||
|   private getCreateFederationTxosTableQuery(): string { | ||||
|     return `CREATE TABLE IF NOT EXISTS federation_txos (
 | ||||
|       txid varchar(65) NOT NULL, | ||||
|       txindex int(11) NOT NULL, | ||||
|       bitcoinaddress varchar(100) NOT NULL, | ||||
|       amount bigint(20) unsigned NOT NULL, | ||||
|       blocknumber int(11) unsigned NOT NULL, | ||||
|       blocktime int(11) unsigned NOT NULL, | ||||
|       unspent tinyint(1) NOT NULL, | ||||
|       lastblockupdate int(11) unsigned NOT NULL, | ||||
|       lasttimeupdate int(11) unsigned NOT NULL, | ||||
|       pegtxid varchar(65) NOT NULL, | ||||
|       pegindex int(11) NOT NULL, | ||||
|       pegblocktime int(11) unsigned NOT NULL, | ||||
|       PRIMARY KEY (txid, txindex),  | ||||
|       FOREIGN KEY (bitcoinaddress) REFERENCES federation_addresses (bitcoinaddress) | ||||
|     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | ||||
|   } | ||||
| 
 | ||||
|   private getCreatePoolsTableQuery(): string { | ||||
|     return `CREATE TABLE IF NOT EXISTS pools (
 | ||||
|       id int(11) NOT NULL AUTO_INCREMENT, | ||||
|  | ||||
| @ -12,6 +12,7 @@ export interface DifficultyAdjustment { | ||||
|   previousTime: number;          // Unix time in ms
 | ||||
|   nextRetargetHeight: number;    // Block Height
 | ||||
|   timeAvg: number;               // Duration of time in ms
 | ||||
|   adjustedTimeAvg;               // Expected block interval with hashrate implied over last 504 blocks
 | ||||
|   timeOffset: number;            // (Testnet) Time since last block (cap @ 20min) in ms
 | ||||
|   expectedBlocks: number;         // Block count
 | ||||
| } | ||||
| @ -80,6 +81,7 @@ export function calcBitsDifference(oldBits: number, newBits: number): number { | ||||
| 
 | ||||
| export function calcDifficultyAdjustment( | ||||
|   DATime: number, | ||||
|   quarterEpochTime: number | null, | ||||
|   nowSeconds: number, | ||||
|   blockHeight: number, | ||||
|   previousRetarget: number, | ||||
| @ -100,8 +102,20 @@ export function calcDifficultyAdjustment( | ||||
| 
 | ||||
|   let difficultyChange = 0; | ||||
|   let timeAvgSecs = blocksInEpoch ? diffSeconds / blocksInEpoch : BLOCK_SECONDS_TARGET; | ||||
|   let adjustedTimeAvgSecs = timeAvgSecs; | ||||
| 
 | ||||
|   // for the first 504 blocks of the epoch, calculate the expected avg block interval
 | ||||
|   // from a sliding window over the last 504 blocks
 | ||||
|   if (quarterEpochTime && blocksInEpoch < 503) { | ||||
|     const timeLastEpoch = DATime - quarterEpochTime; | ||||
|     const adjustedTimeLastEpoch = timeLastEpoch * (1 + (previousRetarget / 100)); | ||||
|     const adjustedTimeSpan = diffSeconds + adjustedTimeLastEpoch; | ||||
|     adjustedTimeAvgSecs = adjustedTimeSpan / 503; | ||||
|     difficultyChange = (BLOCK_SECONDS_TARGET / (adjustedTimeSpan / 504) - 1) * 100; | ||||
|   } else { | ||||
|     difficultyChange = (BLOCK_SECONDS_TARGET / (actualTimespan / (blocksInEpoch + 1)) - 1) * 100; | ||||
|   } | ||||
| 
 | ||||
|   difficultyChange = (BLOCK_SECONDS_TARGET / (actualTimespan / (blocksInEpoch + 1)) - 1) * 100; | ||||
|   // Max increase is x4 (+300%)
 | ||||
|   if (difficultyChange > 300) { | ||||
|     difficultyChange = 300; | ||||
| @ -126,7 +140,8 @@ export function calcDifficultyAdjustment( | ||||
|   } | ||||
| 
 | ||||
|   const timeAvg = Math.floor(timeAvgSecs * 1000); | ||||
|   const remainingTime = remainingBlocks * timeAvg; | ||||
|   const adjustedTimeAvg = Math.floor(adjustedTimeAvgSecs * 1000); | ||||
|   const remainingTime = remainingBlocks * adjustedTimeAvg; | ||||
|   const estimatedRetargetDate = remainingTime + nowSeconds * 1000; | ||||
| 
 | ||||
|   return { | ||||
| @ -139,6 +154,7 @@ export function calcDifficultyAdjustment( | ||||
|     previousTime: DATime, | ||||
|     nextRetargetHeight, | ||||
|     timeAvg, | ||||
|     adjustedTimeAvg, | ||||
|     timeOffset, | ||||
|     expectedBlocks, | ||||
|   }; | ||||
| @ -155,9 +171,10 @@ class DifficultyAdjustmentApi { | ||||
|       return null; | ||||
|     } | ||||
|     const nowSeconds = Math.floor(new Date().getTime() / 1000); | ||||
|     const quarterEpochBlockTime = blocks.getQuarterEpochBlockTime(); | ||||
| 
 | ||||
|     return calcDifficultyAdjustment( | ||||
|       DATime, nowSeconds, blockHeight, previousRetarget, | ||||
|       DATime, quarterEpochBlockTime, nowSeconds, blockHeight, previousRetarget, | ||||
|       config.MEMPOOL.NETWORK, latestBlock.timestamp | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @ -5,8 +5,12 @@ import { Common } from '../common'; | ||||
| import DB from '../../database'; | ||||
| import logger from '../../logger'; | ||||
| 
 | ||||
| const federationChangeAddresses = ['bc1qxvay4an52gcghxq5lavact7r6qe9l4laedsazz8fj2ee2cy47tlqff4aj4', '3EiAcrzq1cELXScc98KeCswGWZaPGceT1d']; | ||||
| const auditBlockOffsetWithTip = 1; // Wait for 1 block confirmation before processing the block in the audit process to reduce the risk of reorgs
 | ||||
| 
 | ||||
| class ElementsParser { | ||||
|   private isRunning = false; | ||||
|   private isUtxosUpdatingRunning = false; | ||||
| 
 | ||||
|   constructor() { } | ||||
| 
 | ||||
| @ -32,12 +36,6 @@ class ElementsParser { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getPegDataByMonth(): Promise<any> { | ||||
|     const query = `SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y-%m-01') AS date FROM elements_pegs GROUP BY DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y%m')`; | ||||
|     const [rows] = await DB.query(query); | ||||
|     return rows; | ||||
|   } | ||||
| 
 | ||||
|   protected async $parseBlock(block: IBitcoinApi.Block) { | ||||
|     for (const tx of block.tx) { | ||||
|       await this.$parseInputs(tx, block); | ||||
| @ -55,29 +53,30 @@ class ElementsParser { | ||||
| 
 | ||||
|   protected async $parsePegIn(input: IBitcoinApi.Vin, vindex: number, txid: string, block: IBitcoinApi.Block) { | ||||
|     const bitcoinTx: IBitcoinApi.Transaction = await bitcoinSecondClient.getRawTransaction(input.txid, true); | ||||
|     const bitcoinBlock: IBitcoinApi.Block = await bitcoinSecondClient.getBlock(bitcoinTx.blockhash); | ||||
|     const prevout = bitcoinTx.vout[input.vout || 0]; | ||||
|     const outputAddress = prevout.scriptPubKey.address || (prevout.scriptPubKey.addresses && prevout.scriptPubKey.addresses[0]) || ''; | ||||
|     await this.$savePegToDatabase(block.height, block.time, prevout.value * 100000000, txid, vindex, | ||||
|       outputAddress, bitcoinTx.txid, prevout.n, 1); | ||||
|       outputAddress, bitcoinTx.txid, prevout.n, bitcoinBlock.height, bitcoinBlock.time, 1); | ||||
|   } | ||||
| 
 | ||||
|   protected async $parseOutputs(tx: IBitcoinApi.Transaction, block: IBitcoinApi.Block) { | ||||
|     for (const output of tx.vout) { | ||||
|       if (output.scriptPubKey.pegout_chain) { | ||||
|         await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n, | ||||
|           (output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 0); | ||||
|           (output.scriptPubKey.pegout_address || ''), '', 0, 0, 0, 0); | ||||
|       } | ||||
|       if (!output.scriptPubKey.pegout_chain && output.scriptPubKey.type === 'nulldata' | ||||
|         && output.value && output.value > 0 && output.asset && output.asset === Common.nativeAssetId) { | ||||
|         await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n, | ||||
|           (output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 1); | ||||
|           (output.scriptPubKey.pegout_address || ''), '', 0, 0, 0, 1); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   protected async $savePegToDatabase(height: number, blockTime: number, amount: number, txid: string, | ||||
|     txindex: number, bitcoinaddress: string, bitcointxid: string, bitcoinindex: number, final_tx: number): Promise<void> { | ||||
|     const query = `INSERT INTO elements_pegs(
 | ||||
|     txindex: number, bitcoinaddress: string, bitcointxid: string, bitcoinindex: number, bitcoinblock: number, bitcoinBlockTime: number, final_tx: number): Promise<void> { | ||||
|     const query = `INSERT IGNORE INTO elements_pegs(
 | ||||
|         block, datetime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx | ||||
|       ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
 | ||||
| 
 | ||||
| @ -85,7 +84,22 @@ class ElementsParser { | ||||
|       height, blockTime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx | ||||
|     ]; | ||||
|     await DB.query(query, params); | ||||
|     logger.debug(`Saved L-BTC peg from block height #${height} with TXID ${txid}.`); | ||||
|     logger.debug(`Saved L-BTC peg from Liquid block height #${height} with TXID ${txid}.`); | ||||
| 
 | ||||
|     if (amount > 0) { // Peg-in
 | ||||
|    | ||||
|       // Add the address to the federation addresses table
 | ||||
|       await DB.query(`INSERT IGNORE INTO federation_addresses (bitcoinaddress) VALUES (?)`, [bitcoinaddress]); | ||||
| 
 | ||||
|       // Add the UTXO to the federation txos table
 | ||||
|       const query_utxos = `INSERT IGNORE INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, pegtxid, pegindex, pegblocktime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; | ||||
|       const params_utxos: (string | number)[] = [bitcointxid, bitcoinindex, bitcoinaddress, amount, bitcoinblock, bitcoinBlockTime, 1, bitcoinblock - 1, 0, txid, txindex, blockTime]; | ||||
|       await DB.query(query_utxos, params_utxos); | ||||
|       const [minBlockUpdate] = await DB.query(`SELECT MIN(lastblockupdate) AS lastblockupdate FROM federation_txos WHERE unspent = 1`) | ||||
|       await this.$saveLastBlockAuditToDatabase(minBlockUpdate[0]['lastblockupdate']); | ||||
|       logger.debug(`Saved new Federation UTXO ${bitcointxid}:${bitcoinindex} belonging to ${bitcoinaddress} to federation txos`); | ||||
| 
 | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   protected async $getLatestBlockHeightFromDatabase(): Promise<number> { | ||||
| @ -98,6 +112,328 @@ class ElementsParser { | ||||
|     const query = `UPDATE state SET number = ? WHERE name = 'last_elements_block'`; | ||||
|     await DB.query(query, [blockHeight]); | ||||
|   } | ||||
| 
 | ||||
|   ///////////// FEDERATION AUDIT //////////////
 | ||||
| 
 | ||||
|   public async $updateFederationUtxos() { | ||||
|     if (this.isUtxosUpdatingRunning) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.isUtxosUpdatingRunning = true; | ||||
| 
 | ||||
|     try { | ||||
|       let auditProgress = await this.$getAuditProgress(); | ||||
|       // If no peg in transaction was found in the database, return
 | ||||
|       if (!auditProgress.lastBlockAudit) { | ||||
|         logger.debug(`No Federation UTXOs found in the database. Waiting for some to be confirmed before starting the Federation UTXOs audit`); | ||||
|         this.isUtxosUpdatingRunning = false; | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState(); | ||||
|       // If the bitcoin blockchain is not synced yet, return
 | ||||
|       if (bitcoinBlocksToSync.bitcoinHeaders > bitcoinBlocksToSync.bitcoinBlocks + 1) { | ||||
|         logger.debug(`Bitcoin client is not synced yet. ${bitcoinBlocksToSync.bitcoinHeaders - bitcoinBlocksToSync.bitcoinBlocks} blocks remaining to sync before the Federation audit process can start`); | ||||
|         this.isUtxosUpdatingRunning = false; | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       auditProgress.lastBlockAudit++; | ||||
| 
 | ||||
|       // Logging
 | ||||
|       let indexedThisRun = 0; | ||||
|       let timer = Date.now() / 1000; | ||||
|       const startedAt = Date.now() / 1000; | ||||
|       const indexingSpeeds: number[] = []; | ||||
| 
 | ||||
|       while (auditProgress.lastBlockAudit <= auditProgress.confirmedTip) { | ||||
| 
 | ||||
|         // First, get the current UTXOs that need to be scanned in the block
 | ||||
|         const utxos = await this.$getFederationUtxosToScan(auditProgress.lastBlockAudit); | ||||
| 
 | ||||
|         // Get the peg-out addresses that need to be scanned
 | ||||
|         const redeemAddresses = await this.$getRedeemAddressesToScan(); | ||||
| 
 | ||||
|         // The fast way: check if these UTXOs are still unspent as of the current block with gettxout
 | ||||
|         let spentAsTip: any[]; | ||||
|         let unspentAsTip: any[]; | ||||
|         if (auditProgress.confirmedTip - auditProgress.lastBlockAudit <= 150) { // If the audit status is not too far in the past, we can use gettxout (fast way)
 | ||||
|           const utxosToParse = await this.$getFederationUtxosToParse(utxos); | ||||
|           spentAsTip = utxosToParse.spentAsTip; | ||||
|           unspentAsTip = utxosToParse.unspentAsTip; | ||||
|           logger.debug(`Found ${utxos.length} Federation UTXOs and ${redeemAddresses.length} Peg-Out Addresses to scan in Bitcoin block height #${auditProgress.lastBlockAudit} / #${auditProgress.confirmedTip}`); | ||||
|           logger.debug(`${unspentAsTip.length} / ${utxos.length} Federation UTXOs are unspent as of tip`); | ||||
|         } else { // If the audit status is too far in the past, it is useless and wasteful to look for still unspent txos since they will all be spent as of the tip
 | ||||
|           spentAsTip = utxos; | ||||
|           unspentAsTip = []; | ||||
| 
 | ||||
|           // Logging
 | ||||
|           const elapsedSeconds = (Date.now() / 1000) - timer; | ||||
|           if (elapsedSeconds > 5) { | ||||
|             const runningFor = (Date.now() / 1000) - startedAt; | ||||
|             const blockPerSeconds = indexedThisRun / elapsedSeconds; | ||||
|             indexingSpeeds.push(blockPerSeconds); | ||||
|             if (indexingSpeeds.length > 100) indexingSpeeds.shift(); // Keep the length of the up to 100 last indexing speeds
 | ||||
|             const meanIndexingSpeed = indexingSpeeds.reduce((a, b) => a + b, 0) / indexingSpeeds.length; | ||||
|             const eta = (auditProgress.confirmedTip - auditProgress.lastBlockAudit) / meanIndexingSpeed; | ||||
|             logger.debug(`Scanning ${utxos.length} Federation UTXOs and ${redeemAddresses.length} Peg-Out Addresses at Bitcoin block height #${auditProgress.lastBlockAudit} / #${auditProgress.confirmedTip} | ~${meanIndexingSpeed.toFixed(2)} blocks/sec | elapsed: ${(runningFor / 60).toFixed(0)} minutes | ETA: ${(eta / 60).toFixed(0)} minutes`); | ||||
|             timer = Date.now() / 1000; | ||||
|             indexedThisRun = 0; | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         // The slow way: parse the block to look for the spending tx
 | ||||
|         const blockHash: IBitcoinApi.ChainTips = await bitcoinSecondClient.getBlockHash(auditProgress.lastBlockAudit); | ||||
|         const block: IBitcoinApi.Block = await bitcoinSecondClient.getBlock(blockHash, 2); | ||||
|         await this.$parseBitcoinBlock(block, spentAsTip, unspentAsTip, auditProgress.confirmedTip, redeemAddresses); | ||||
| 
 | ||||
|         // Finally, update the lastblockupdate of the remaining UTXOs and save to the database
 | ||||
|         const [minBlockUpdate] = await DB.query(`SELECT MIN(lastblockupdate) AS lastblockupdate FROM federation_txos WHERE unspent = 1`) | ||||
|         await this.$saveLastBlockAuditToDatabase(minBlockUpdate[0]['lastblockupdate']); | ||||
| 
 | ||||
|         auditProgress = await this.$getAuditProgress(); | ||||
|         auditProgress.lastBlockAudit++; | ||||
|         indexedThisRun++; | ||||
|       } | ||||
| 
 | ||||
|       this.isUtxosUpdatingRunning = false; | ||||
|     } catch (e) { | ||||
|       this.isUtxosUpdatingRunning = false; | ||||
|       throw new Error(e instanceof Error ? e.message : 'Error'); | ||||
|     }  | ||||
|   } | ||||
| 
 | ||||
|   // Get the UTXOs that need to be scanned in block height (UTXOs that were last updated in the block height - 1)
 | ||||
|   protected async $getFederationUtxosToScan(height: number) {  | ||||
|     const query = `SELECT txid, txindex, bitcoinaddress, amount FROM federation_txos WHERE lastblockupdate = ? AND unspent = 1`; | ||||
|     const [rows] = await DB.query(query, [height - 1]); | ||||
|     return rows as any[]; | ||||
|   } | ||||
| 
 | ||||
|   // Returns the UTXOs that are spent as of tip and need to be scanned
 | ||||
|   protected async $getFederationUtxosToParse(utxos: any[]): Promise<any> { | ||||
|     const spentAsTip: any[] = []; | ||||
|     const unspentAsTip: any[] = []; | ||||
| 
 | ||||
|     for (const utxo of utxos) { | ||||
|       const result = await bitcoinSecondClient.getTxOut(utxo.txid, utxo.txindex, false); | ||||
|       result ? unspentAsTip.push(utxo) : spentAsTip.push(utxo); | ||||
|     } | ||||
|      | ||||
|     return {spentAsTip, unspentAsTip}; | ||||
|   } | ||||
| 
 | ||||
|   protected async $parseBitcoinBlock(block: IBitcoinApi.Block, spentAsTip: any[], unspentAsTip: any[], confirmedTip: number, redeemAddressesData: any[] = []) { | ||||
|     const redeemAddresses: string[] = redeemAddressesData.map(redeemAddress => redeemAddress.bitcoinaddress); | ||||
|     for (const tx of block.tx) { | ||||
|       let mightRedeemInThisTx = false; // If a Federation UTXO is spent in this block, we might find a peg-out address in the outputs...
 | ||||
|       // Check if the Federation UTXOs that was spent as of tip are spent in this block
 | ||||
|       for (const input of tx.vin) { | ||||
|         const txo = spentAsTip.find(txo => txo.txid === input.txid && txo.txindex === input.vout); | ||||
|         if (txo) { | ||||
|           mightRedeemInThisTx = true; | ||||
|           await DB.query(`UPDATE federation_txos SET unspent = 0, lastblockupdate = ?, lasttimeupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, block.time, txo.txid, txo.txindex]); | ||||
|           // Remove the TXO from the utxo array
 | ||||
|           spentAsTip.splice(spentAsTip.indexOf(txo), 1); | ||||
|           logger.debug(`Federation UTXO ${txo.txid}:${txo.txindex} (${txo.amount} sats) was spent in block ${block.height}`); | ||||
|         } | ||||
|       } | ||||
|       // Check if an output is sent to a change address of the federation
 | ||||
|       for (const output of tx.vout) { | ||||
|         if (output.scriptPubKey.address && federationChangeAddresses.includes(output.scriptPubKey.address)) { | ||||
|           // Check that the UTXO was not already added in the DB by previous scans
 | ||||
|           const [rows_check] = await DB.query(`SELECT txid FROM federation_txos WHERE txid = ? AND txindex = ?`, [tx.txid, output.n]) as any[]; | ||||
|           if (rows_check.length === 0) { | ||||
|             const query_utxos = `INSERT INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, pegtxid, pegindex, pegblocktime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; | ||||
|             const params_utxos: (string | number)[] = [tx.txid, output.n, output.scriptPubKey.address, output.value * 100000000, block.height, block.time, 1, block.height, 0, '', 0, 0]; | ||||
|             await DB.query(query_utxos, params_utxos); | ||||
|             // Add the UTXO to the utxo array
 | ||||
|             spentAsTip.push({ | ||||
|               txid: tx.txid, | ||||
|               txindex: output.n, | ||||
|               bitcoinaddress: output.scriptPubKey.address, | ||||
|               amount: output.value * 100000000 | ||||
|             }); | ||||
|             logger.debug(`Added new Federation UTXO ${tx.txid}:${output.n} (${output.value * 100000000} sats), change address: ${output.scriptPubKey.address}`); | ||||
|           } | ||||
|         } | ||||
|         if (mightRedeemInThisTx && output.scriptPubKey.address && redeemAddresses.includes(output.scriptPubKey.address)) { | ||||
|           // Find the number of times output.scriptPubKey.address appears in redeemAddresses. There can be address reuse for peg-outs...
 | ||||
|           const matchingAddress: any[] = redeemAddressesData.filter(redeemAddress => redeemAddress.bitcoinaddress === output.scriptPubKey.address && -redeemAddress.amount === Math.round(output.value * 100000000)); | ||||
|           if (matchingAddress.length > 0) { | ||||
|             if (matchingAddress.length > 1) { | ||||
|               // If there are more than one peg out address with the same amount, we can't know which one redeemed the UTXO: we take the oldest one
 | ||||
|               matchingAddress.sort((a, b) => a.datetime - b.datetime); | ||||
|               logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${matchingAddress[0].bitcoinaddress}, amount ${matchingAddress[0].amount}, datetime ${matchingAddress[0].datetime}`); | ||||
|             } else { | ||||
|               logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${matchingAddress[0].bitcoinaddress}, amount ${matchingAddress[0].amount}`); | ||||
|             } | ||||
|             const query_add_redeem = `UPDATE elements_pegs SET bitcointxid = ?, bitcoinindex = ? WHERE bitcoinaddress = ? AND amount = ? AND datetime = ?`; | ||||
|             const params_add_redeem: (string | number)[] = [tx.txid, output.n, matchingAddress[0].bitcoinaddress, matchingAddress[0].amount, matchingAddress[0].datetime]; | ||||
|             await DB.query(query_add_redeem, params_add_redeem); | ||||
|             const index = redeemAddressesData.indexOf(matchingAddress[0]); | ||||
|             redeemAddressesData.splice(index, 1); | ||||
|             redeemAddresses.splice(index, 1); | ||||
|           } else { // The output amount does not match the peg-out amount... log it
 | ||||
|             logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${output.scriptPubKey.address} but output amount ${Math.round(output.value * 100000000)} does not match the peg-out amount!`); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     for (const utxo of spentAsTip) { | ||||
|       await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, utxo.txid, utxo.txindex]);     | ||||
|     } | ||||
| 
 | ||||
|     for (const utxo of unspentAsTip) { | ||||
|       await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [confirmedTip, utxo.txid, utxo.txindex]); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   protected async $saveLastBlockAuditToDatabase(blockHeight: number) { | ||||
|     const query = `UPDATE state SET number = ? WHERE name = 'last_bitcoin_block_audit'`; | ||||
|     await DB.query(query, [blockHeight]); | ||||
|   } | ||||
| 
 | ||||
|   // Get the bitcoin block where the audit process was last updated
 | ||||
|   protected async $getAuditProgress(): Promise<any> { | ||||
|     const lastblockaudit = await this.$getLastBlockAudit(); | ||||
|     const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState(); | ||||
|     return { | ||||
|       lastBlockAudit: lastblockaudit, | ||||
|       confirmedTip: bitcoinBlocksToSync.bitcoinBlocks - auditBlockOffsetWithTip, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   // Get the bitcoin blocks remaining to be synced
 | ||||
|   protected async $getBitcoinBlockchainState(): Promise<any> { | ||||
|     const result = await bitcoinSecondClient.getBlockchainInfo(); | ||||
|     return { | ||||
|       bitcoinBlocks: result.blocks, | ||||
|       bitcoinHeaders: result.headers, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   protected async $getLastBlockAudit(): Promise<number> { | ||||
|     const query = `SELECT number FROM state WHERE name = 'last_bitcoin_block_audit'`; | ||||
|     const [rows] = await DB.query(query); | ||||
|     return rows[0]['number']; | ||||
|   } | ||||
| 
 | ||||
|   protected async $getRedeemAddressesToScan(): Promise<any[]> { | ||||
|     const query = `SELECT datetime, amount, bitcoinaddress FROM elements_pegs where amount < 0 AND bitcoinaddress != '' AND bitcointxid = '';`; | ||||
|     const [rows]: any[] = await DB.query(query); | ||||
|     return rows; | ||||
|   } | ||||
| 
 | ||||
|   ///////////// DATA QUERY //////////////
 | ||||
| 
 | ||||
|   public async $getAuditStatus(): Promise<any> { | ||||
|     const lastBlockAudit = await this.$getLastBlockAudit(); | ||||
|     const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState(); | ||||
|     return { | ||||
|       bitcoinBlocks: bitcoinBlocksToSync.bitcoinBlocks, | ||||
|       bitcoinHeaders: bitcoinBlocksToSync.bitcoinHeaders, | ||||
|       lastBlockAudit: lastBlockAudit, | ||||
|       isAuditSynced: bitcoinBlocksToSync.bitcoinHeaders - bitcoinBlocksToSync.bitcoinBlocks <= 2 && bitcoinBlocksToSync.bitcoinBlocks - lastBlockAudit <= 3, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   public async $getPegDataByMonth(): Promise<any> { | ||||
|     const query = `SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y-%m-01') AS date FROM elements_pegs GROUP BY DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y%m')`; | ||||
|     const [rows] = await DB.query(query); | ||||
|     return rows; | ||||
|   } | ||||
| 
 | ||||
|   public async $getFederationReservesByMonth(): Promise<any> { | ||||
|     const query = ` | ||||
|     SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(blocktime), '%Y-%m-01') AS date FROM federation_txos  | ||||
|     WHERE | ||||
|         (blocktime > UNIX_TIMESTAMP(LAST_DAY(FROM_UNIXTIME(blocktime) - INTERVAL 1 MONTH) + INTERVAL 1 DAY)) | ||||
|       AND  | ||||
|         ((unspent = 1) OR (unspent = 0 AND lasttimeupdate > UNIX_TIMESTAMP(LAST_DAY(FROM_UNIXTIME(blocktime)) + INTERVAL 1 DAY))) | ||||
|     GROUP BY  | ||||
|         date;`;          
 | ||||
|     const [rows] = await DB.query(query); | ||||
|     return rows; | ||||
|   } | ||||
| 
 | ||||
|   // Get the current L-BTC pegs and the last Liquid block it was updated
 | ||||
|   public async $getCurrentLbtcSupply(): Promise<any> { | ||||
|     const [rows] = await DB.query(`SELECT SUM(amount) AS LBTC_supply FROM elements_pegs;`); | ||||
|     const lastblockupdate = await this.$getLatestBlockHeightFromDatabase(); | ||||
|     const hash = await bitcoinClient.getBlockHash(lastblockupdate); | ||||
|     return { | ||||
|       amount: rows[0]['LBTC_supply'], | ||||
|       lastBlockUpdate: lastblockupdate, | ||||
|       hash: hash | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   // Get the current reserves of the federation and the last Bitcoin block it was updated
 | ||||
|   public async $getCurrentFederationReserves(): Promise<any> { | ||||
|     const [rows] = await DB.query(`SELECT SUM(amount) AS total_balance FROM federation_txos WHERE unspent = 1;`); | ||||
|     const lastblockaudit = await this.$getLastBlockAudit(); | ||||
|     const hash = await bitcoinSecondClient.getBlockHash(lastblockaudit); | ||||
|     return { | ||||
|       amount: rows[0]['total_balance'], | ||||
|       lastBlockUpdate: lastblockaudit, | ||||
|       hash: hash | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   // Get all of the federation addresses, most balances first
 | ||||
|   public async $getFederationAddresses(): Promise<any> { | ||||
|     const query = `SELECT bitcoinaddress, SUM(amount) AS balance FROM federation_txos WHERE unspent = 1 GROUP BY bitcoinaddress ORDER BY balance DESC;`; | ||||
|     const [rows] = await DB.query(query); | ||||
|     return rows; | ||||
|   } | ||||
| 
 | ||||
|   // Get all of the UTXOs held by the federation, most recent first
 | ||||
|   public async $getFederationUtxos(): Promise<any> { | ||||
|     const query = `SELECT txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, pegtxid, pegindex, pegblocktime FROM federation_txos WHERE unspent = 1 ORDER BY blocktime DESC;`; | ||||
|     const [rows] = await DB.query(query); | ||||
|     return rows; | ||||
|   } | ||||
| 
 | ||||
|   // Get the total number of federation addresses
 | ||||
|   public async $getFederationAddressesNumber(): Promise<any> { | ||||
|     const query = `SELECT COUNT(DISTINCT bitcoinaddress) AS address_count FROM federation_txos WHERE unspent = 1;`; | ||||
|     const [rows] = await DB.query(query); | ||||
|     return rows[0]; | ||||
|   } | ||||
| 
 | ||||
|   // Get the total number of federation utxos
 | ||||
|   public async $getFederationUtxosNumber(): Promise<any> { | ||||
|     const query = `SELECT COUNT(*) AS utxo_count FROM federation_txos WHERE unspent = 1;`; | ||||
|     const [rows] = await DB.query(query); | ||||
|     return rows[0]; | ||||
|   } | ||||
| 
 | ||||
|   // Get recent pegs in / out
 | ||||
|   public async $getPegsList(count: number = 0): Promise<any> { | ||||
|     const query = `SELECT txid, txindex, amount, bitcoinaddress, bitcointxid, bitcoinindex, datetime AS blocktime FROM elements_pegs ORDER BY block DESC LIMIT 15 OFFSET ?;`; | ||||
|     const [rows] = await DB.query(query, [count]); | ||||
|     return rows; | ||||
|   } | ||||
| 
 | ||||
|   // Get all peg in / out from the last month
 | ||||
|   public async $getPegsVolumeDaily(): Promise<any> { | ||||
|     const pegInQuery = await DB.query(`SELECT SUM(amount) AS volume, COUNT(*) AS number FROM elements_pegs WHERE amount > 0 and datetime > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -1, CURRENT_TIMESTAMP()));`); | ||||
|     const pegOutQuery = await DB.query(`SELECT SUM(amount) AS volume, COUNT(*) AS number FROM elements_pegs WHERE amount < 0 and datetime > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -1, CURRENT_TIMESTAMP()));`); | ||||
|     return [ | ||||
|       pegInQuery[0][0], | ||||
|       pegOutQuery[0][0] | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|   // Get the total pegs number
 | ||||
|   public async $getPegsCount(): Promise<any> { | ||||
|     const [rows] = await DB.query(`SELECT COUNT(*) AS pegs_count FROM elements_pegs;`); | ||||
|     return rows[0]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new ElementsParser(); | ||||
|  | ||||
| @ -15,7 +15,18 @@ class LiquidRoutes { | ||||
|      | ||||
|     if (config.DATABASE.ENABLED) { | ||||
|       app | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs', this.$getElementsPegs) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', this.$getElementsPegsByMonth) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/list/:count', this.$getPegsList) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/volume', this.$getPegsVolumeDaily) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/count', this.$getPegsCount) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves', this.$getFederationReserves) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/month', this.$getFederationReservesByMonth) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses', this.$getFederationAddresses) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses/total', this.$getFederationAddressesNumber) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos', this.$getFederationUtxos) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/total', this.$getFederationUtxosNumber) | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/status', this.$getFederationAuditStatus) | ||||
|         ; | ||||
|     } | ||||
|   } | ||||
| @ -63,11 +74,147 @@ class LiquidRoutes { | ||||
|   private async $getElementsPegsByMonth(req: Request, res: Response) { | ||||
|     try { | ||||
|       const pegs = await elementsParser.$getPegDataByMonth(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); | ||||
|       res.json(pegs); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getFederationReservesByMonth(req: Request, res: Response) { | ||||
|     try { | ||||
|       const reserves = await elementsParser.$getFederationReservesByMonth(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); | ||||
|       res.json(reserves); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getElementsPegs(req: Request, res: Response) { | ||||
|     try { | ||||
|       const currentSupply = await elementsParser.$getCurrentLbtcSupply(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(currentSupply); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getFederationReserves(req: Request, res: Response) { | ||||
|     try { | ||||
|       const currentReserves = await elementsParser.$getCurrentFederationReserves(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(currentReserves); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getFederationAuditStatus(req: Request, res: Response) { | ||||
|     try { | ||||
|       const auditStatus = await elementsParser.$getAuditStatus(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(auditStatus); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getFederationAddresses(req: Request, res: Response) { | ||||
|     try { | ||||
|       const federationAddresses = await elementsParser.$getFederationAddresses(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(federationAddresses); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getFederationAddressesNumber(req: Request, res: Response) { | ||||
|     try { | ||||
|       const federationAddresses = await elementsParser.$getFederationAddressesNumber(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(federationAddresses); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getFederationUtxos(req: Request, res: Response) { | ||||
|     try { | ||||
|       const federationUtxos = await elementsParser.$getFederationUtxos(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(federationUtxos); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getFederationUtxosNumber(req: Request, res: Response) { | ||||
|     try { | ||||
|       const federationUtxos = await elementsParser.$getFederationUtxosNumber(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(federationUtxos); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getPegsList(req: Request, res: Response) { | ||||
|     try { | ||||
|       const recentPegs = await elementsParser.$getPegsList(parseInt(req.params?.count)); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(recentPegs); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getPegsVolumeDaily(req: Request, res: Response) { | ||||
|     try { | ||||
|       const pegsVolume = await elementsParser.$getPegsVolumeDaily(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(pegsVolume); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getPegsCount(req: Request, res: Response) { | ||||
|     try { | ||||
|       const pegsCount = await elementsParser.$getPegsCount(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); | ||||
|       res.json(pegsCount); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default new LiquidRoutes(); | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { GbtGenerator, GbtResult, ThreadTransaction as RustThreadTransaction, ThreadAcceleration as RustThreadAcceleration } from 'rust-gbt'; | ||||
| import logger from '../logger'; | ||||
| import { MempoolBlock, MempoolTransactionExtended, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats, PoolTag, TransactionClassified } from '../mempool.interfaces'; | ||||
| import { MempoolBlock, MempoolTransactionExtended, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats, PoolTag, TransactionClassified, TransactionCompressed, MempoolDeltaChange } from '../mempool.interfaces'; | ||||
| import { Common, OnlineFeeStatsCalculator } from './common'; | ||||
| import config from '../config'; | ||||
| import { Worker } from 'worker_threads'; | ||||
| @ -171,7 +171,7 @@ class MempoolBlocks { | ||||
|     for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { | ||||
|       let added: TransactionClassified[] = []; | ||||
|       let removed: string[] = []; | ||||
|       const changed: { txid: string, rate: number | undefined, acc: boolean | undefined }[] = []; | ||||
|       const changed: TransactionClassified[] = []; | ||||
|       if (mempoolBlocks[i] && !prevBlocks[i]) { | ||||
|         added = mempoolBlocks[i].transactions; | ||||
|       } else if (!mempoolBlocks[i] && prevBlocks[i]) { | ||||
| @ -194,14 +194,14 @@ class MempoolBlocks { | ||||
|           if (!prevIds[tx.txid]) { | ||||
|             added.push(tx); | ||||
|           } else if (tx.rate !== prevIds[tx.txid].rate || tx.acc !== prevIds[tx.txid].acc) { | ||||
|             changed.push({ txid: tx.txid, rate: tx.rate, acc: tx.acc }); | ||||
|             changed.push(tx); | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|       mempoolBlockDeltas.push({ | ||||
|         added, | ||||
|         added: added.map(this.compressTx), | ||||
|         removed, | ||||
|         changed, | ||||
|         changed: changed.map(this.compressDeltaChange), | ||||
|       }); | ||||
|     } | ||||
|     return mempoolBlockDeltas; | ||||
| @ -691,6 +691,38 @@ class MempoolBlocks { | ||||
|     }); | ||||
|     return { blocks: convertedBlocks, blockWeights, rates: convertedRates, clusters: convertedClusters, overflow: convertedOverflow }; | ||||
|   } | ||||
| 
 | ||||
|   public compressTx(tx: TransactionClassified): TransactionCompressed { | ||||
|     if (tx.acc) { | ||||
|       return [ | ||||
|         tx.txid, | ||||
|         tx.fee, | ||||
|         tx.vsize, | ||||
|         tx.value, | ||||
|         Math.round((tx.rate || (tx.fee / tx.vsize)) * 100) / 100, | ||||
|         tx.flags, | ||||
|         1 | ||||
|       ]; | ||||
|     } else { | ||||
|       return [ | ||||
|         tx.txid, | ||||
|         tx.fee, | ||||
|         tx.vsize, | ||||
|         tx.value, | ||||
|         Math.round((tx.rate || (tx.fee / tx.vsize)) * 100) / 100, | ||||
|         tx.flags, | ||||
|       ]; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public compressDeltaChange(tx: TransactionClassified): MempoolDeltaChange { | ||||
|     return [ | ||||
|       tx.txid, | ||||
|       Math.round((tx.rate || (tx.fee / tx.vsize)) * 100) / 100, | ||||
|       tx.flags, | ||||
|       tx.acc ? 1 : 0, | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new MempoolBlocks(); | ||||
|  | ||||
| @ -142,7 +142,7 @@ class Mining { | ||||
|   public async $getPoolStat(slug: string): Promise<object> { | ||||
|     const pool = await PoolsRepository.$getPool(slug); | ||||
|     if (!pool) { | ||||
|       throw new Error('This mining pool does not exist ' + escape(slug)); | ||||
|       throw new Error('This mining pool does not exist'); | ||||
|     } | ||||
| 
 | ||||
|     const blockCount: number = await BlocksRepository.$blockCount(pool.id); | ||||
|  | ||||
| @ -285,7 +285,7 @@ class StatisticsApi { | ||||
| 
 | ||||
|   public async $list2H(): Promise<OptimizedStatistic[]> { | ||||
|     try { | ||||
|       const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics ORDER BY statistics.added DESC LIMIT 120`; | ||||
|       const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL 2 HOUR) AND NOW() ORDER BY statistics.added DESC`; | ||||
|       const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout }); | ||||
|       return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); | ||||
|     } catch (e) { | ||||
| @ -296,7 +296,7 @@ class StatisticsApi { | ||||
| 
 | ||||
|   public async $list24H(): Promise<OptimizedStatistic[]> { | ||||
|     try { | ||||
|       const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics ORDER BY statistics.added DESC LIMIT 1440`; | ||||
|       const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL 24 HOUR) AND NOW() ORDER BY statistics.added DESC`; | ||||
|       const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout }); | ||||
|       return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); | ||||
|     } catch (e) { | ||||
|  | ||||
| @ -6,6 +6,7 @@ import statisticsApi from './statistics-api'; | ||||
| 
 | ||||
| class Statistics { | ||||
|   protected intervalTimer: NodeJS.Timer | undefined; | ||||
|   protected lastRun: number = 0; | ||||
|   protected newStatisticsEntryCallback: ((stats: OptimizedStatistic) => void) | undefined; | ||||
| 
 | ||||
|   public setNewStatisticsEntryCallback(fn: (stats: OptimizedStatistic) => void) { | ||||
| @ -23,15 +24,21 @@ class Statistics { | ||||
|     setTimeout(() => { | ||||
|       this.runStatistics(); | ||||
|       this.intervalTimer = setInterval(() => { | ||||
|         this.runStatistics(); | ||||
|         this.runStatistics(true); | ||||
|       }, 1 * 60 * 1000); | ||||
|     }, difference); | ||||
|   } | ||||
| 
 | ||||
|   private async runStatistics(): Promise<void> { | ||||
|   public async runStatistics(skipIfRecent = false): Promise<void> { | ||||
|     if (!memPool.isInSync()) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (skipIfRecent && new Date().getTime() / 1000 - this.lastRun < 30) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.lastRun = new Date().getTime() / 1000; | ||||
|     const currentMempool = memPool.getMempool(); | ||||
|     const txPerSecond = memPool.getTxPerSecond(); | ||||
|     const vBytesPerSecond = memPool.getVBytesPerSecond(); | ||||
|  | ||||
| @ -23,6 +23,7 @@ import priceUpdater from '../tasks/price-updater'; | ||||
| import { ApiPrice } from '../repositories/PricesRepository'; | ||||
| import accelerationApi from './services/acceleration'; | ||||
| import mempool from './mempool'; | ||||
| import statistics from './statistics/statistics'; | ||||
| 
 | ||||
| interface AddressTransactions { | ||||
|   mempool: MempoolTransactionExtended[], | ||||
| @ -259,7 +260,7 @@ class WebsocketHandler { | ||||
|               const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); | ||||
|               response['projected-block-transactions'] = JSON.stringify({ | ||||
|                 index: index, | ||||
|                 blockTransactions: mBlocksWithTransactions[index]?.transactions || [], | ||||
|                 blockTransactions: (mBlocksWithTransactions[index]?.transactions || []).map(mempoolBlocks.compressTx), | ||||
|               }); | ||||
|             } else { | ||||
|               client['track-mempool-block'] = null; | ||||
| @ -723,6 +724,7 @@ class WebsocketHandler { | ||||
|     } | ||||
| 
 | ||||
|     this.printLogs(); | ||||
|     await statistics.runStatistics(); | ||||
| 
 | ||||
|     const _memPool = memPool.getMempool(); | ||||
| 
 | ||||
| @ -999,7 +1001,7 @@ class WebsocketHandler { | ||||
|           if (mBlockDeltas[index].added.length > (mBlocksWithTransactions[index]?.transactions.length / 2)) { | ||||
|             response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-full-${index}`, { | ||||
|               index: index, | ||||
|               blockTransactions: mBlocksWithTransactions[index].transactions, | ||||
|               blockTransactions: mBlocksWithTransactions[index].transactions.map(mempoolBlocks.compressTx), | ||||
|             }); | ||||
|           } else { | ||||
|             response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-delta-${index}`, { | ||||
| @ -1014,6 +1016,8 @@ class WebsocketHandler { | ||||
|         client.send(this.serializeResponse(response)); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     await statistics.runStatistics(); | ||||
|   } | ||||
| 
 | ||||
|   // takes a dictionary of JSON serialized values
 | ||||
|  | ||||
| @ -266,6 +266,7 @@ class Server { | ||||
|       blocks.setNewBlockCallback(async () => { | ||||
|         try { | ||||
|           await elementsParser.$parse(); | ||||
|           await elementsParser.$updateFederationUtxos(); | ||||
|         } catch (e) { | ||||
|           logger.warn('Elements parsing error: ' + (e instanceof Error ? e.message : e)); | ||||
|         } | ||||
|  | ||||
| @ -185,7 +185,8 @@ class Indexer { | ||||
|       await blocks.$generateCPFPDatabase(); | ||||
|       await blocks.$generateAuditStats(); | ||||
|       await auditReplicator.$sync(); | ||||
|       await blocks.$classifyBlocks(); | ||||
|       // do not wait for classify blocks to finish
 | ||||
|       blocks.$classifyBlocks(); | ||||
|     } catch (e) { | ||||
|       this.indexerRunning = false; | ||||
|       logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|  | ||||
| @ -65,9 +65,9 @@ export interface MempoolBlockWithTransactions extends MempoolBlock { | ||||
| } | ||||
| 
 | ||||
| export interface MempoolBlockDelta { | ||||
|   added: TransactionClassified[]; | ||||
|   added: TransactionCompressed[]; | ||||
|   removed: string[]; | ||||
|   changed: { txid: string, rate: number | undefined, flags?: number }[]; | ||||
|   changed: MempoolDeltaChange[]; | ||||
| } | ||||
| 
 | ||||
| interface VinStrippedToScriptsig { | ||||
| @ -196,6 +196,11 @@ export interface TransactionClassified extends TransactionStripped { | ||||
|   flags: number; | ||||
| } | ||||
| 
 | ||||
| // [txid, fee, vsize, value, rate, flags, acceleration?]
 | ||||
| export type TransactionCompressed = [string, number, number, number, number, number, 1?]; | ||||
| // [txid, rate, flags, acceleration?]
 | ||||
| export type MempoolDeltaChange = [string, number, number, (1|0)]; | ||||
| 
 | ||||
| // binary flags for transaction classification
 | ||||
| export const TransactionFlags = { | ||||
|   // features
 | ||||
|  | ||||
| @ -59,7 +59,7 @@ class BlocksAuditRepositories { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getBlockAudit(hash: string): Promise<any> { | ||||
|   public async $getBlockAudit(hash: string): Promise<BlockAudit | null> { | ||||
|     try { | ||||
|       const [rows]: any[] = await DB.query( | ||||
|         `SELECT blocks_audits.height, blocks_audits.hash as id, UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
 | ||||
| @ -75,8 +75,8 @@ class BlocksAuditRepositories { | ||||
|         expected_weight as expectedWeight | ||||
|         FROM blocks_audits | ||||
|         JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash | ||||
|         WHERE blocks_audits.hash = "${hash}" | ||||
|       `);
 | ||||
|         WHERE blocks_audits.hash = ? | ||||
|       `, [hash]);
 | ||||
|        | ||||
|       if (rows.length) { | ||||
|         rows[0].missingTxs = JSON.parse(rows[0].missingTxs); | ||||
| @ -101,8 +101,8 @@ class BlocksAuditRepositories { | ||||
|       const [rows]: any[] = await DB.query( | ||||
|         `SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight
 | ||||
|         FROM blocks_audits | ||||
|         WHERE blocks_audits.hash = "${hash}" | ||||
|       `);
 | ||||
|         WHERE blocks_audits.hash = ? | ||||
|       `, [hash]);
 | ||||
|       return rows[0]; | ||||
|     } catch (e: any) { | ||||
|       logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|  | ||||
| @ -5,7 +5,7 @@ import logger from '../logger'; | ||||
| import { Common } from '../api/common'; | ||||
| import PoolsRepository from './PoolsRepository'; | ||||
| import HashratesRepository from './HashratesRepository'; | ||||
| import { escape } from 'mysql2'; | ||||
| import { RowDataPacket, escape } from 'mysql2'; | ||||
| import BlocksSummariesRepository from './BlocksSummariesRepository'; | ||||
| import DifficultyAdjustmentsRepository from './DifficultyAdjustmentsRepository'; | ||||
| import bitcoinClient from '../api/bitcoin/bitcoin-client'; | ||||
| @ -478,7 +478,7 @@ class BlocksRepository { | ||||
|   public async $getBlocksByPool(slug: string, startHeight?: number): Promise<BlockExtended[]> { | ||||
|     const pool = await PoolsRepository.$getPool(slug); | ||||
|     if (!pool) { | ||||
|       throw new Error('This mining pool does not exist ' + escape(slug)); | ||||
|       throw new Error('This mining pool does not exist'); | ||||
|     } | ||||
| 
 | ||||
|     const params: any[] = []; | ||||
| @ -802,10 +802,10 @@ class BlocksRepository { | ||||
|   /** | ||||
|    * Get a list of blocks that have been indexed | ||||
|    */ | ||||
|   public async $getIndexedBlocks(): Promise<any[]> { | ||||
|   public async $getIndexedBlocks(): Promise<{ height: number, hash: string }[]> { | ||||
|     try { | ||||
|       const [rows]: any = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`); | ||||
|       return rows; | ||||
|       const [rows] = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`) as RowDataPacket[][]; | ||||
|       return rows as { height: number, hash: string }[]; | ||||
|     } catch (e) { | ||||
|       logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e)); | ||||
|       throw e; | ||||
| @ -815,7 +815,7 @@ class BlocksRepository { | ||||
|   /** | ||||
|    * Get a list of blocks that have not had CPFP data indexed | ||||
|    */ | ||||
|    public async $getCPFPUnindexedBlocks(): Promise<any[]> { | ||||
|    public async $getCPFPUnindexedBlocks(): Promise<number[]> { | ||||
|     try { | ||||
|       const blockchainInfo = await bitcoinClient.getBlockchainInfo(); | ||||
|       const currentBlockHeight = blockchainInfo.blocks; | ||||
| @ -825,13 +825,13 @@ class BlocksRepository { | ||||
|       } | ||||
|       const minHeight = Math.max(0, currentBlockHeight - indexingBlockAmount + 1); | ||||
| 
 | ||||
|       const [rows]: any[] = await DB.query(` | ||||
|       const [rows] = await DB.query(` | ||||
|         SELECT height | ||||
|         FROM compact_cpfp_clusters | ||||
|         WHERE height <= ? AND height >= ? | ||||
|         GROUP BY height | ||||
|         ORDER BY height DESC; | ||||
|       `, [currentBlockHeight, minHeight]);
 | ||||
|       `, [currentBlockHeight, minHeight]) as RowDataPacket[][];
 | ||||
| 
 | ||||
|       const indexedHeights = {}; | ||||
|       rows.forEach((row) => { indexedHeights[row.height] = true; }); | ||||
|  | ||||
| @ -1,3 +1,4 @@ | ||||
| import { RowDataPacket } from 'mysql2'; | ||||
| import DB from '../database'; | ||||
| import logger from '../logger'; | ||||
| import { BlockSummary, TransactionClassified } from '../mempool.interfaces'; | ||||
| @ -69,7 +70,7 @@ class BlocksSummariesRepository { | ||||
| 
 | ||||
|   public async $getIndexedSummariesId(): Promise<string[]> { | ||||
|     try { | ||||
|       const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries`); | ||||
|       const [rows] = await DB.query(`SELECT id from blocks_summaries`) as RowDataPacket[][]; | ||||
|       return rows.map(row => row.id); | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get block summaries id list. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|  | ||||
| @ -139,7 +139,7 @@ class HashratesRepository { | ||||
|   public async $getPoolWeeklyHashrate(slug: string): Promise<any[]> { | ||||
|     const pool = await PoolsRepository.$getPool(slug); | ||||
|     if (!pool) { | ||||
|       throw new Error('This mining pool does not exist ' + escape(slug)); | ||||
|       throw new Error('This mining pool does not exist'); | ||||
|     } | ||||
| 
 | ||||
|     // Find hashrate boundaries
 | ||||
|  | ||||
| @ -31,6 +31,9 @@ const curveP = BigInt(`0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF | ||||
|  * @returns {boolean} true if the point is on the SECP256K1 curve | ||||
|  */ | ||||
| export function isPoint(pointHex: string): boolean { | ||||
|   if (!pointHex?.length) { | ||||
|     return false; | ||||
|   } | ||||
|   if ( | ||||
|     !( | ||||
|       // is uncompressed
 | ||||
|  | ||||
							
								
								
									
										3
									
								
								contributors/jamesblacklock.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/jamesblacklock.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of December 20, 2023. | ||||
| 
 | ||||
| Signed: jamesblacklock | ||||
| @ -1,3 +1,3 @@ | ||||
| I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of November 16, 2023. | ||||
| 
 | ||||
| Signed: natsee | ||||
| Signed: natsoni | ||||
| @ -35,7 +35,7 @@ | ||||
|     "ALLOW_UNREACHABLE": __MEMPOOL_ALLOW_UNREACHABLE__, | ||||
|     "POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__", | ||||
|     "POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__", | ||||
|     "PRICE_UPDATES_PER_HOUR": __MEMPOOL_PRICE_UPDATES_PER_HOUR__ | ||||
|     "PRICE_UPDATES_PER_HOUR": __MEMPOOL_PRICE_UPDATES_PER_HOUR__, | ||||
|     "MAX_TRACKED_ADDRESSES": __MEMPOOL_MAX_TRACKED_ADDRESSES__ | ||||
|   }, | ||||
|   "CORE_RPC": { | ||||
|  | ||||
| @ -55,7 +55,7 @@ __ELECTRUM_TLS_ENABLED__=${ELECTRUM_TLS_ENABLED:=false} | ||||
| 
 | ||||
| # ESPLORA | ||||
| __ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000} | ||||
| __ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:="null"} | ||||
| __ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:=""} | ||||
| __ESPLORA_BATCH_QUERY_BASE_SIZE__=${ESPLORA_BATCH_QUERY_BASE_SIZE:=1000} | ||||
| __ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000} | ||||
| __ESPLORA_REQUEST_TIMEOUT__=${ESPLORA_REQUEST_TIMEOUT:=5000} | ||||
|  | ||||
							
								
								
									
										7
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -6,6 +6,13 @@ | ||||
| /out-tsc | ||||
| server.run.js | ||||
| 
 | ||||
| # docker | ||||
| Dockerfile | ||||
| entrypoint.sh | ||||
| nginx-mempool.conf | ||||
| nginx.conf | ||||
| wait-for | ||||
| 
 | ||||
| # Only exists if Bazel was run | ||||
| /bazel-out | ||||
| 
 | ||||
|  | ||||
| @ -22,6 +22,7 @@ import { FiatCurrencyPipe } from './shared/pipes/fiat-currency.pipe'; | ||||
| import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe'; | ||||
| import { CapAddressPipe } from './shared/pipes/cap-address-pipe/cap-address-pipe'; | ||||
| import { AppPreloadingStrategy } from './app.preloading-strategy'; | ||||
| import { ServicesApiServices } from './services/services-api.service'; | ||||
| 
 | ||||
| const providers = [ | ||||
|   ElectrsApiService, | ||||
| @ -40,6 +41,7 @@ const providers = [ | ||||
|   FiatCurrencyPipe, | ||||
|   CapAddressPipe, | ||||
|   AppPreloadingStrategy, | ||||
|   ServicesApiServices, | ||||
|   { provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true } | ||||
| ]; | ||||
| 
 | ||||
|  | ||||
| @ -1,16 +1,16 @@ | ||||
| <div id="become-sponsor-container"> | ||||
| <div id="become-sponsor-container" [ngClass]="context"> | ||||
|   <div class="become-sponsor community"> | ||||
|     <p style="font-weight: 700; font-size: 18px;">If you're an individual...</p> | ||||
|     <a href="https://mempool.space/sponsor" class="btn" style="background-color: rgba(152, 88, 255, 0.75); box-shadow: 0px 0px 50px 5px rgba(152, 88, 255, 0.75)" i18n="about.community-sponsor-button" (click)="onSponsorClick($event)">Become a Community Sponsor</a> | ||||
|     <a [href]="host + '/sponsor'" class="btn" style="background-color: rgba(152, 88, 255, 0.75); box-shadow: 0px 0px 50px 5px rgba(152, 88, 255, 0.75)" i18n="about.community-sponsor-button" (click)="onSponsorClick($event)">Become a Community Sponsor</a> | ||||
|     <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Exclusive swag</p> | ||||
|     <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Your avatar on the About page</p> | ||||
|     <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> And more coming soon :)</p> | ||||
|   </div> | ||||
|   <div class="become-sponsor enterprise"> | ||||
|     <p style="font-weight: 700; font-size: 18px;">If you're a business...</p> | ||||
|     <a href="https://mempool.space/enterprise" class="btn" style="background-color: rgba(152, 88, 255, 0.75); box-shadow: 0px 0px 50px 5px rgba(152, 88, 255, 0.75)" i18n="about.enterprise-sponsor-button" (click)="onEnterpriseClick($event)">Become an Enterprise Sponsor</a> | ||||
|     <a [href]="host + '/enterprise'" class="btn" style="background-color: rgba(152, 88, 255, 0.75); box-shadow: 0px 0px 50px 5px rgba(152, 88, 255, 0.75)" i18n="about.enterprise-sponsor-button" (click)="onEnterpriseClick($event)">Become an Enterprise Sponsor</a> | ||||
|     <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Increased API limits</p> | ||||
|     <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Co-branded instance</p> | ||||
|     <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> 99% service-level agreement</p> | ||||
|   </div> | ||||
| </div> | ||||
| </div> | ||||
|  | ||||
| @ -6,6 +6,11 @@ | ||||
|   align-items: center; | ||||
|   gap: 20px; | ||||
|   margin: 68px auto; | ||||
|   text-align: center; | ||||
| } | ||||
| 
 | ||||
| #become-sponsor-container.account { | ||||
|   margin: 20px auto; | ||||
| } | ||||
| 
 | ||||
| .become-sponsor { | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import { Component } from '@angular/core'; | ||||
| import { Component, Input } from '@angular/core'; | ||||
| import { EnterpriseService } from '../../services/enterprise.service'; | ||||
| 
 | ||||
| @Component({ | ||||
| @ -7,6 +7,9 @@ import { EnterpriseService } from '../../services/enterprise.service'; | ||||
|   styleUrls: ['./about-sponsors.component.scss'], | ||||
| }) | ||||
| export class AboutSponsorsComponent { | ||||
|   @Input() host = 'https://mempool.space'; | ||||
|   @Input() context = 'about'; | ||||
| 
 | ||||
|   constructor(private enterpriseService: EnterpriseService) { | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -4,6 +4,7 @@ import { Subscription, catchError, of, tap } from 'rxjs'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { Transaction } from '../../interfaces/electrs.interface'; | ||||
| import { nextRoundNumber } from '../../shared/common.utils'; | ||||
| import { ServicesApiServices } from '../../services/services-api.service'; | ||||
| import { AudioService } from '../../services/audio.service'; | ||||
| 
 | ||||
| export type AccelerationEstimate = { | ||||
| @ -62,7 +63,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges | ||||
|   maxRateOptions: RateOption[] = []; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private servicesApiService: ServicesApiServices, | ||||
|     private storageService: StorageService, | ||||
|     private audioService: AudioService, | ||||
|     private cd: ChangeDetectorRef | ||||
| @ -83,7 +84,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges | ||||
|   ngOnInit() { | ||||
|     this.user = this.storageService.getAuth()?.user ?? null; | ||||
| 
 | ||||
|     this.estimateSubscription = this.apiService.estimate$(this.tx.txid).pipe( | ||||
|     this.estimateSubscription = this.servicesApiService.estimate$(this.tx.txid).pipe( | ||||
|       tap((response) => { | ||||
|         if (response.status === 204) { | ||||
|           this.estimate = undefined; | ||||
| @ -183,7 +184,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges | ||||
|     if (this.accelerationSubscription) { | ||||
|       this.accelerationSubscription.unsubscribe(); | ||||
|     } | ||||
|     this.accelerationSubscription = this.apiService.accelerate$( | ||||
|     this.accelerationSubscription = this.servicesApiService.accelerate$( | ||||
|       this.tx.txid, | ||||
|       this.userBid | ||||
|     ).subscribe({ | ||||
| @ -213,4 +214,4 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges | ||||
|   onResize(): void { | ||||
|     this.isMobile = window.innerWidth <= 767.98; | ||||
|   } | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -27,12 +27,6 @@ | ||||
|     </form> | ||||
|   </div> | ||||
| 
 | ||||
|   <div *ngIf="widget"> | ||||
|     <div class="item"> | ||||
|       <h5 class="card-title" i18n="acceleration.total-bid-boost">Total Bid Boost</h5> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <div [class.chart]="!widget" [class.chart-widget]="widget" echarts [initOpts]="chartInitOptions" [options]="chartOptions" | ||||
|     (chartInit)="onChartInit($event)"> | ||||
|   </div> | ||||
|  | ||||
| @ -53,11 +53,6 @@ | ||||
|     padding-bottom: 55px; | ||||
|   } | ||||
| } | ||||
| .chart-widget { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   max-height: 290px; | ||||
| } | ||||
| 
 | ||||
| h5 { | ||||
|   margin-bottom: 10px; | ||||
|  | ||||
| @ -1,8 +1,7 @@ | ||||
| import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { EChartsOption, graphic } from 'echarts'; | ||||
| import { Observable, Subscription, combineLatest } from 'rxjs'; | ||||
| import { Observable, Subscription, combineLatest, fromEvent } from 'rxjs'; | ||||
| import { map, max, startWith, switchMap, tap } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../../services/api.service'; | ||||
| import { SeoService } from '../../../services/seo.service'; | ||||
| import { formatNumber } from '@angular/common'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; | ||||
| @ -11,6 +10,8 @@ import { StorageService } from '../../../services/storage.service'; | ||||
| import { MiningService } from '../../../services/mining.service'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { Acceleration } from '../../../interfaces/node-api.interface'; | ||||
| import { ServicesApiServices } from '../../../services/services-api.service'; | ||||
| import { ApiService } from '../../../services/api.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-acceleration-fees-graph', | ||||
| @ -28,6 +29,7 @@ import { Acceleration } from '../../../interfaces/node-api.interface'; | ||||
| }) | ||||
| export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { | ||||
|   @Input() widget: boolean = false; | ||||
|   @Input() height: number | string = '200'; | ||||
|   @Input() right: number | string = 45; | ||||
|   @Input() left: number | string = 75; | ||||
|   @Input() accelerations$: Observable<Acceleration[]>; | ||||
| @ -54,6 +56,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private seoService: SeoService, | ||||
|     private apiService: ApiService, | ||||
|     private servicesApiService: ServicesApiServices, | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private storageService: StorageService, | ||||
|     private miningService: MiningService, | ||||
| @ -72,8 +75,9 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { | ||||
|       this.timespan = this.miningWindowPreference; | ||||
| 
 | ||||
|       this.statsObservable$ = combineLatest([ | ||||
|         (this.accelerations$ || this.apiService.getAccelerationHistory$({ timeframe: this.miningWindowPreference })), | ||||
|         (this.accelerations$ || this.servicesApiService.getAccelerationHistory$({ timeframe: this.miningWindowPreference })), | ||||
|         this.apiService.getHistoricalBlockFees$(this.miningWindowPreference), | ||||
|         fromEvent(window, 'resize').pipe(startWith(null)), | ||||
|       ]).pipe( | ||||
|         tap(([accelerations, blockFeesResponse]) => { | ||||
|           this.prepareChartOptions(accelerations, blockFeesResponse.body); | ||||
| @ -101,7 +105,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { | ||||
|             this.isLoading = true; | ||||
|             this.storageService.setValue('miningWindowPreference', timespan); | ||||
|             this.timespan = timespan; | ||||
|             return this.apiService.getAccelerationHistory$({}); | ||||
|             return this.servicesApiService.getAccelerationHistory$({}); | ||||
|           }) | ||||
|         ), | ||||
|         this.radioGroupForm.get('dateSpan').valueChanges.pipe( | ||||
| @ -173,6 +177,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { | ||||
|       ], | ||||
|       animation: false, | ||||
|       grid: { | ||||
|         height: this.height, | ||||
|         right: this.right, | ||||
|         left: this.left, | ||||
|         bottom: this.widget ? 30 : 80, | ||||
|  | ||||
| @ -1,9 +1,9 @@ | ||||
| import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core'; | ||||
| import { Observable, catchError, of, switchMap, tap } from 'rxjs'; | ||||
| import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; | ||||
| import { ApiService } from '../../../services/api.service'; | ||||
| import { StateService } from '../../../services/state.service'; | ||||
| import { WebsocketService } from '../../../services/websocket.service'; | ||||
| import { ServicesApiServices } from '../../../services/services-api.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-accelerations-list', | ||||
| @ -26,7 +26,7 @@ export class AccelerationsListComponent implements OnInit { | ||||
|   skeletonLines: number[] = []; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private servicesApiService: ServicesApiServices, | ||||
|     private websocketService: WebsocketService, | ||||
|     public stateService: StateService, | ||||
|     private cd: ChangeDetectorRef, | ||||
| @ -41,7 +41,7 @@ export class AccelerationsListComponent implements OnInit { | ||||
|     this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()]; | ||||
|     this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; | ||||
| 
 | ||||
|     const accelerationObservable$ = this.accelerations$ || (this.pending ? this.apiService.getAccelerations$() : this.apiService.getAccelerationHistory$({ timeframe: '1m' })); | ||||
|     const accelerationObservable$ = this.accelerations$ || (this.pending ? this.servicesApiService.getAccelerations$() : this.servicesApiService.getAccelerationHistory$({ timeframe: '1m' })); | ||||
|     this.accelerationList$ = accelerationObservable$.pipe( | ||||
|       switchMap(accelerations => { | ||||
|         if (this.pending) { | ||||
|  | ||||
| @ -37,6 +37,11 @@ | ||||
|     <div class="col" style="margin-bottom: 1.47rem"> | ||||
|       <div class="card"> | ||||
|         <div class="card-body pl-lg-3 pr-lg-3 pl-2 pr-2"> | ||||
|           <a class="title-link" href="" [routerLink]="['/mempool-block/0' | relativeUrl]"> | ||||
|             <h5 class="card-title d-inline" i18n="dashboard.mempool-goggles-accelerations">Mempool Goggles: Accelerations</h5> | ||||
|             <span> </span> | ||||
|             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: #4a68b9"></fa-icon> | ||||
|           </a> | ||||
|           <div class="mempool-block-wrapper"> | ||||
|             <app-mempool-block-overview [index]="0" [overrideColors]="getAcceleratorColor"></app-mempool-block-overview> | ||||
|           </div> | ||||
| @ -48,7 +53,15 @@ | ||||
|     <div class="col" style="margin-bottom: 1.47rem"> | ||||
|       <div class="card graph-card"> | ||||
|         <div class="card-body pl-2 pr-2"> | ||||
|           <app-acceleration-fees-graph [attr.data-cy]="'acceleration-fees'" [widget]=true [accelerations$]="accelerations$"></app-acceleration-fees-graph> | ||||
|           <h5 class="card-title" i18n="acceleration.total-bid-boost">Total Bid Boost</h5> | ||||
|           <div class="mempool-graph"> | ||||
|             <app-acceleration-fees-graph | ||||
|               [height]="graphHeight" | ||||
|               [attr.data-cy]="'acceleration-fees'" | ||||
|               [widget]=true | ||||
|               [accelerations$]="accelerations$" | ||||
|             ></app-acceleration-fees-graph> | ||||
|           </div> | ||||
|           <div class="mt-1"><a [attr.data-cy]="'acceleration-fees-view-more'" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
| @ -17,6 +17,16 @@ | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .mempool-graph { | ||||
|   height: 295px; | ||||
|   @media (min-width: 768px) { | ||||
|     height: 325px; | ||||
|   } | ||||
|   @media (min-width: 992px) { | ||||
|     height: 409px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .card-title { | ||||
|   font-size: 1rem; | ||||
|   color: #4a68b9; | ||||
| @ -135,7 +145,12 @@ | ||||
| } | ||||
| 
 | ||||
| .card { | ||||
|   height: 385px; | ||||
|   @media (min-width: 768px) { | ||||
|     height: 420px; | ||||
|   } | ||||
|   @media (min-width: 992px) { | ||||
|     height: 510px; | ||||
|   } | ||||
| } | ||||
| .list-card { | ||||
|   height: 410px; | ||||
| @ -145,7 +160,16 @@ | ||||
| } | ||||
| 
 | ||||
| .mempool-block-wrapper { | ||||
|   max-height: 380px; | ||||
|   max-width: 380px; | ||||
|   max-height: 430px; | ||||
|   max-width: 430px; | ||||
|   margin: auto; | ||||
| 
 | ||||
|   @media (min-width: 768px) { | ||||
|     max-height: 344px; | ||||
|     max-width: 344px; | ||||
|   } | ||||
|   @media (min-width: 992px) { | ||||
|     max-height: 430px; | ||||
|     max-width: 430px; | ||||
|   } | ||||
| } | ||||
| @ -1,14 +1,14 @@ | ||||
| import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | ||||
| import { ChangeDetectionStrategy, Component, HostListener, OnInit } from '@angular/core'; | ||||
| import { SeoService } from '../../../services/seo.service'; | ||||
| import { WebsocketService } from '../../../services/websocket.service'; | ||||
| import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; | ||||
| import { StateService } from '../../../services/state.service'; | ||||
| import { Observable, Subject, catchError, combineLatest, distinctUntilChanged, interval, map, of, share, startWith, switchMap, tap } from 'rxjs'; | ||||
| import { ApiService } from '../../../services/api.service'; | ||||
| import { Observable, catchError, combineLatest, distinctUntilChanged, interval, map, of, share, startWith, switchMap, tap } from 'rxjs'; | ||||
| import { Color } from '../../block-overview-graph/sprite-types'; | ||||
| import { hexToColor } from '../../block-overview-graph/utils'; | ||||
| import TxView from '../../block-overview-graph/tx-view'; | ||||
| import { feeLevels, mempoolFeeColors } from '../../../app.constants'; | ||||
| import { ServicesApiServices } from '../../../services/services-api.service'; | ||||
| 
 | ||||
| const acceleratedColor: Color = hexToColor('8F5FF6'); | ||||
| const normalColors = mempoolFeeColors.map(hex => hexToColor(hex + '5F')); | ||||
| @ -30,43 +30,48 @@ export class AcceleratorDashboardComponent implements OnInit { | ||||
|   minedAccelerations$: Observable<Acceleration[]>; | ||||
|   loadingBlocks: boolean = true; | ||||
| 
 | ||||
|   graphHeight: number = 300; | ||||
| 
 | ||||
|   constructor( | ||||
|     private seoService: SeoService, | ||||
|     private websocketService: WebsocketService, | ||||
|     private apiService: ApiService, | ||||
|     private serviceApiServices: ServicesApiServices, | ||||
|     private stateService: StateService, | ||||
|   ) { | ||||
|     this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Accelerator Dashboard`); | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.onResize(); | ||||
|     this.websocketService.want(['blocks', 'mempool-blocks', 'stats']); | ||||
| 
 | ||||
|     this.pendingAccelerations$ = interval(30000).pipe( | ||||
|       startWith(true), | ||||
|       switchMap(() => { | ||||
|         return this.apiService.getAccelerations$(); | ||||
|       }), | ||||
|       catchError((e) => { | ||||
|         return of([]); | ||||
|         return this.serviceApiServices.getAccelerations$().pipe( | ||||
|           catchError(() => { | ||||
|             return of([]); | ||||
|           }), | ||||
|         ); | ||||
|       }), | ||||
|       share(), | ||||
|     ); | ||||
| 
 | ||||
|     this.accelerations$ = this.stateService.chainTip$.pipe( | ||||
|       distinctUntilChanged(), | ||||
|       switchMap((chainTip) => { | ||||
|         return this.apiService.getAccelerationHistory$({ timeframe: '1m' }); | ||||
|       }), | ||||
|       catchError((e) => { | ||||
|         return of([]); | ||||
|       switchMap(() => { | ||||
|         return this.serviceApiServices.getAccelerationHistory$({ timeframe: '1m' }).pipe( | ||||
|           catchError(() => { | ||||
|             return of([]); | ||||
|           }), | ||||
|         ); | ||||
|       }), | ||||
|       share(), | ||||
|     ); | ||||
| 
 | ||||
|     this.minedAccelerations$ = this.accelerations$.pipe( | ||||
|       map(accelerations => { | ||||
|         return accelerations.filter(acc => ['mined', 'completed'].includes(acc.status)) | ||||
|         return accelerations.filter(acc => ['mined', 'completed', 'failed'].includes(acc.status)); | ||||
|       }) | ||||
|     ); | ||||
| 
 | ||||
| @ -119,4 +124,15 @@ export class AcceleratorDashboardComponent implements OnInit { | ||||
|       return normalColors[feeLevelIndex] || normalColors[mempoolFeeColors.length - 1]; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('window:resize', ['$event']) | ||||
|   onResize(): void { | ||||
|     if (window.innerWidth >= 992) { | ||||
|       this.graphHeight = 330; | ||||
|     } else if (window.innerWidth >= 768) { | ||||
|       this.graphHeight = 245; | ||||
|     } else { | ||||
|       this.graphHeight = 210; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; | ||||
| import { Observable, of } from 'rxjs'; | ||||
| import { switchMap } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../../services/api.service'; | ||||
| import { Acceleration } from '../../../interfaces/node-api.interface'; | ||||
| import { ServicesApiServices } from '../../../services/services-api.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-pending-stats', | ||||
| @ -15,11 +15,11 @@ export class PendingStatsComponent implements OnInit { | ||||
|   public accelerationStats$: Observable<any>; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private servicesApiService: ServicesApiServices, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.accelerationStats$ = (this.accelerations$ || this.apiService.getAccelerations$()).pipe( | ||||
|     this.accelerationStats$ = (this.accelerations$ || this.servicesApiService.getAccelerations$()).pipe( | ||||
|       switchMap(accelerations => { | ||||
|         let totalAccelerations = 0; | ||||
|         let totalFeeDelta = 0; | ||||
|  | ||||
| @ -43,7 +43,7 @@ export class AddressLabelsComponent implements OnChanges { | ||||
| 
 | ||||
|   handleVin() { | ||||
|     if (this.vin.inner_witnessscript_asm) { | ||||
|       if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) { | ||||
|       if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0 || this.vin.inner_witnessscript_asm.indexOf('OP_PUSHNUM_15 OP_CHECKMULTISIG OP_IFDUP OP_NOTIF OP_PUSHBYTES_2') === 1259) { | ||||
|         if (this.vin.witness.length > 11) { | ||||
|           this.label = 'Liquid Peg Out'; | ||||
|         } else { | ||||
|  | ||||
| @ -31,8 +31,7 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|   addressLoadingStatus$: Observable<number>; | ||||
|   addressInfo: null | AddressInformation = null; | ||||
| 
 | ||||
|   totalConfirmedTxCount = 0; | ||||
|   loadedConfirmedTxCount = 0; | ||||
|   fullyLoaded = false; | ||||
|   txCount = 0; | ||||
|   received = 0; | ||||
|   sent = 0; | ||||
| @ -66,7 +65,7 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|         switchMap((params: ParamMap) => { | ||||
|           this.error = undefined; | ||||
|           this.isLoadingAddress = true; | ||||
|           this.loadedConfirmedTxCount = 0; | ||||
|           this.fullyLoaded = false; | ||||
|           this.address = null; | ||||
|           this.isLoadingTransactions = true; | ||||
|           this.transactions = null; | ||||
| @ -105,7 +104,7 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|       .pipe( | ||||
|         filter((address) => !!address), | ||||
|         tap((address: Address) => { | ||||
|           if ((this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') && /^([m-zA-HJ-NP-Z1-9]{26,35}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[a-km-zA-HJ-NP-Z1-9]{80})$/.test(address.address)) { | ||||
|           if ((this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') && /^([a-zA-HJ-NP-Z1-9]{26,35}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[a-km-zA-HJ-NP-Z1-9]{80})$/.test(address.address)) { | ||||
|             this.apiService.validateAddress$(address.address) | ||||
|               .subscribe((addressInfo) => { | ||||
|                 this.addressInfo = addressInfo; | ||||
| @ -128,7 +127,6 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|           this.tempTransactions = transactions; | ||||
|           if (transactions.length) { | ||||
|             this.lastTransactionTxId = transactions[transactions.length - 1].txid; | ||||
|             this.loadedConfirmedTxCount += transactions.filter((tx) => tx.status.confirmed).length; | ||||
|           } | ||||
| 
 | ||||
|           const fetchTxs: string[] = []; | ||||
| @ -191,8 +189,6 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|             this.audioService.playSound('magic'); | ||||
|           } | ||||
|         } | ||||
|         this.totalConfirmedTxCount++; | ||||
|         this.loadedConfirmedTxCount++; | ||||
|       }); | ||||
|   } | ||||
| 
 | ||||
| @ -252,16 +248,19 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|   } | ||||
| 
 | ||||
|   loadMore() { | ||||
|     if (this.isLoadingTransactions || !this.totalConfirmedTxCount || this.loadedConfirmedTxCount >= this.totalConfirmedTxCount) { | ||||
|     if (this.isLoadingTransactions || this.fullyLoaded) { | ||||
|       return; | ||||
|     } | ||||
|     this.isLoadingTransactions = true; | ||||
|     this.retryLoadMore = false; | ||||
|     this.electrsApiService.getAddressTransactions$(this.address.address, this.lastTransactionTxId) | ||||
|       .subscribe((transactions: Transaction[]) => { | ||||
|         this.lastTransactionTxId = transactions[transactions.length - 1].txid; | ||||
|         this.loadedConfirmedTxCount += transactions.length; | ||||
|         this.transactions = this.transactions.concat(transactions); | ||||
|         if (transactions && transactions.length) { | ||||
|           this.lastTransactionTxId = transactions[transactions.length - 1].txid; | ||||
|           this.transactions = this.transactions.concat(transactions); | ||||
|         } else { | ||||
|           this.fullyLoaded = true; | ||||
|         } | ||||
|         this.isLoadingTransactions = false; | ||||
|       }, | ||||
|       (error) => { | ||||
| @ -278,7 +277,6 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|     this.received = this.address.chain_stats.funded_txo_sum + this.address.mempool_stats.funded_txo_sum; | ||||
|     this.sent = this.address.chain_stats.spent_txo_sum + this.address.mempool_stats.spent_txo_sum; | ||||
|     this.txCount = this.address.chain_stats.tx_count + this.address.mempool_stats.tx_count; | ||||
|     this.totalConfirmedTxCount = this.address.chain_stats.tx_count; | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy() { | ||||
|  | ||||
| @ -19,7 +19,7 @@ | ||||
|   </ng-template> | ||||
|   <ng-template #default> | ||||
|     ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }} | ||||
|     <span class="symbol"><ng-template [ngIf]="network === 'liquid'">L-</ng-template> | ||||
|     <span class="symbol"><ng-template [ngIf]="network === 'liquid' && !forceBtc">L-</ng-template> | ||||
|     <ng-template [ngIf]="network === 'liquidtestnet'">tL-</ng-template> | ||||
|     <ng-template [ngIf]="network === 'testnet'">t</ng-template> | ||||
|     <ng-template [ngIf]="network === 'signet'">s</ng-template>BTC</span> | ||||
|  | ||||
| @ -23,6 +23,7 @@ export class AmountComponent implements OnInit, OnDestroy { | ||||
|   @Input() noFiat = false; | ||||
|   @Input() addPlus = false; | ||||
|   @Input() blockConversion: Price; | ||||
|   @Input() forceBtc: boolean = false; | ||||
| 
 | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| <div class="block-filters" [class.filters-active]="activeFilters.length > 0" [class.menu-open]="menuOpen" [class.small]="cssWidth < 500" [class.vsmall]="cssWidth < 400" [class.tiny]="cssWidth < 200"> | ||||
| <div class="block-filters" [class.filters-active]="activeFilters.length > 0" [class.any-mode]="filterMode === 'or'" [class.menu-open]="menuOpen" [class.small]="cssWidth < 500" [class.vsmall]="cssWidth < 400" [class.tiny]="cssWidth < 200"> | ||||
|   <a *ngIf="menuOpen" [routerLink]="['/docs/faq' | relativeUrl]" fragment="how-do-mempool-goggles-work" class="info-badges" i18n-ngbTooltip="Mempool Goggles tooltip" ngbTooltip="select filter categories to highlight matching transactions"> | ||||
|     <span class="badge badge-pill badge-warning beta" i18n="beta">beta</span> | ||||
|     <fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true" size="lg"></fa-icon> | ||||
| @ -14,6 +14,15 @@ | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="filter-menu" *ngIf="menuOpen && cssWidth > 280"> | ||||
|     <h5>Match</h5> | ||||
|     <div class="btn-group btn-group-toggle"> | ||||
|       <label class="btn btn-xs blue mode-toggle" [class.active]="filterMode === 'and'"> | ||||
|         <input type="radio" [value]="'all'" fragment="all" (click)="setFilterMode('and')">All | ||||
|       </label> | ||||
|       <label class="btn btn-xs green mode-toggle" [class.active]="filterMode === 'or'"> | ||||
|         <input type="radio" [value]="'any'" fragment="any" (click)="setFilterMode('or')">Any | ||||
|       </label> | ||||
|     </div> | ||||
|     <ng-container *ngFor="let group of filterGroups;"> | ||||
|       <h5>{{ group.label }}</h5> | ||||
|       <div class="filter-group"> | ||||
|  | ||||
| @ -77,6 +77,49 @@ | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &.any-mode { | ||||
|     .filter-tag { | ||||
|       border: solid 1px #1a9436; | ||||
|       &.selected { | ||||
|         background-color: #1a9436; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .btn-group { | ||||
|     font-size: 0.9em; | ||||
|     margin-right: 0.25em; | ||||
|   } | ||||
| 
 | ||||
|   .mode-toggle { | ||||
|     padding: 0.2em 0.5em; | ||||
|     pointer-events: all; | ||||
|     line-height: 1.5; | ||||
|     background: #181b2daf; | ||||
| 
 | ||||
|     &:first-child { | ||||
|       border-top-left-radius: 0.2rem; | ||||
|       border-bottom-left-radius: 0.2rem; | ||||
|     } | ||||
|     &:last-child { | ||||
|       border-top-right-radius: 0.2rem; | ||||
|       border-bottom-right-radius: 0.2rem; | ||||
|     } | ||||
| 
 | ||||
|     &.blue { | ||||
|       border: solid 1px #105fb0; | ||||
|       &.active { | ||||
|         background: #105fb0; | ||||
|       } | ||||
|     } | ||||
|     &.green { | ||||
|       border: solid 1px #1a9436; | ||||
|       &.active { | ||||
|         background: #1a9436; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   :host-context(.block-overview-graph:hover) &, &:hover, &:active { | ||||
|     .menu-toggle { | ||||
|       opacity: 0.5; | ||||
| @ -132,6 +175,11 @@ | ||||
|     .filter-tag { | ||||
|       font-size: 0.7em; | ||||
|     } | ||||
|     .mode-toggle { | ||||
|       font-size: 0.7em; | ||||
|       margin-bottom: 5px; | ||||
|       margin-top: 2px; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &.tiny { | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { Component, EventEmitter, Output, HostListener, Input, ChangeDetectorRef, OnChanges, SimpleChanges, OnInit, OnDestroy } from '@angular/core'; | ||||
| import { FilterGroups, TransactionFilters } from '../../shared/filters.utils'; | ||||
| import { ActiveFilter, FilterGroups, FilterMode, TransactionFilters } from '../../shared/filters.utils'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| 
 | ||||
| @ -12,7 +12,7 @@ import { Subscription } from 'rxjs'; | ||||
| export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   @Input() cssWidth: number = 800; | ||||
|   @Input() excludeFilters: string[] = []; | ||||
|   @Output() onFilterChanged: EventEmitter<bigint | null> = new EventEmitter(); | ||||
|   @Output() onFilterChanged: EventEmitter<ActiveFilter | null> = new EventEmitter(); | ||||
| 
 | ||||
|   filterSubscription: Subscription; | ||||
| 
 | ||||
| @ -21,6 +21,7 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   disabledFilters: { [key: string]: boolean } = {}; | ||||
|   activeFilters: string[] = []; | ||||
|   filterFlags: { [key: string]: boolean } = {}; | ||||
|   filterMode: FilterMode = 'and'; | ||||
|   menuOpen: boolean = false; | ||||
| 
 | ||||
|   constructor( | ||||
| @ -29,15 +30,16 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   ) {} | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.filterSubscription = this.stateService.activeGoggles$.subscribe((activeFilters: string[]) => { | ||||
|     this.filterSubscription = this.stateService.activeGoggles$.subscribe((active: ActiveFilter) => { | ||||
|       this.filterMode = active.mode; | ||||
|       for (const key of Object.keys(this.filterFlags)) { | ||||
|         this.filterFlags[key] = false; | ||||
|       } | ||||
|       for (const key of activeFilters) { | ||||
|       for (const key of active.filters) { | ||||
|         this.filterFlags[key] = !this.disabledFilters[key]; | ||||
|       } | ||||
|       this.activeFilters = [...activeFilters.filter(key => !this.disabledFilters[key])]; | ||||
|       this.onFilterChanged.emit(this.getBooleanFlags()); | ||||
|       this.activeFilters = [...active.filters.filter(key => !this.disabledFilters[key])]; | ||||
|       this.onFilterChanged.emit({ mode: active.mode, filters: this.activeFilters }); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
| @ -53,6 +55,12 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setFilterMode(mode): void { | ||||
|     this.filterMode = mode; | ||||
|     this.onFilterChanged.emit({ mode: this.filterMode, filters: this.activeFilters }); | ||||
|     this.stateService.activeGoggles$.next({ mode: this.filterMode, filters: [...this.activeFilters] }); | ||||
|   } | ||||
| 
 | ||||
|   toggleFilter(key): void { | ||||
|     const filter = this.filters[key]; | ||||
|     this.filterFlags[key] = !this.filterFlags[key]; | ||||
| @ -73,8 +81,8 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy { | ||||
|       this.activeFilters = this.activeFilters.filter(f => f != key); | ||||
|     } | ||||
|     const booleanFlags = this.getBooleanFlags(); | ||||
|     this.onFilterChanged.emit(booleanFlags); | ||||
|     this.stateService.activeGoggles$.next([...this.activeFilters]); | ||||
|     this.onFilterChanged.emit({ mode: this.filterMode, filters: this.activeFilters }); | ||||
|     this.stateService.activeGoggles$.next({ mode: this.filterMode, filters: [...this.activeFilters] }); | ||||
|   } | ||||
|    | ||||
|   getBooleanFlags(): bigint | null { | ||||
| @ -90,7 +98,7 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   @HostListener('document:click', ['$event']) | ||||
|   onClick(event): boolean { | ||||
|     // click away from menu
 | ||||
|     if (!event.target.closest('button')) { | ||||
|     if (!event.target.closest('button') && !event.target.closest('label')) { | ||||
|       this.menuOpen = false; | ||||
|     } | ||||
|     return true; | ||||
|  | ||||
| @ -13,6 +13,9 @@ | ||||
|       [auditEnabled]="auditHighlighting" | ||||
|       [blockConversion]="blockConversion" | ||||
|     ></app-block-overview-tooltip> | ||||
|     <app-block-filters *ngIf="showFilters && filtersAvailable" [excludeFilters]="excludeFilters" [cssWidth]="cssWidth" (onFilterChanged)="setFilterFlags($event)"></app-block-filters> | ||||
|     <app-block-filters *ngIf="webGlEnabled && showFilters && filtersAvailable" [excludeFilters]="excludeFilters" [cssWidth]="cssWidth" (onFilterChanged)="setFilterFlags($event)"></app-block-filters> | ||||
|     <div *ngIf="!webGlEnabled" class="placeholder"> | ||||
|       <span i18n="webgl-disabled">Your browser does not support this feature.</span> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
| @ -7,6 +7,19 @@ | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   grid-column: 1/-1; | ||||
| 
 | ||||
|   .placeholder { | ||||
|     display: flex; | ||||
|     position: absolute; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     top: 0; | ||||
|     bottom: 0; | ||||
|     height: 100%; | ||||
|     width: 100%; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .grid-align { | ||||
|  | ||||
| @ -9,6 +9,8 @@ import { Price } from '../../services/price.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { defaultColorFunction, setOpacity, defaultFeeColors, defaultAuditFeeColors, defaultMarginalFeeColors, defaultAuditColors } from './utils'; | ||||
| import { ActiveFilter, FilterMode, toFlags } from '../../shared/filters.utils'; | ||||
| import { detectWebGL } from '../../shared/graphs.utils'; | ||||
| 
 | ||||
| const unmatchedOpacity = 0.2; | ||||
| const unmatchedFeeColors = defaultFeeColors.map(c => setOpacity(c, unmatchedOpacity)); | ||||
| @ -42,6 +44,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|   @Input() showFilters: boolean = false; | ||||
|   @Input() excludeFilters: string[] = []; | ||||
|   @Input() filterFlags: bigint | null = null; | ||||
|   @Input() filterMode: FilterMode = 'and'; | ||||
|   @Input() blockConversion: Price; | ||||
|   @Input() overrideColors: ((tx: TxView) => Color) | null = null; | ||||
|   @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>(); | ||||
| @ -75,11 +78,14 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|   filtersAvailable: boolean = true; | ||||
|   activeFilterFlags: bigint | null = null; | ||||
| 
 | ||||
|   webGlEnabled = true; | ||||
| 
 | ||||
|   constructor( | ||||
|     readonly ngZone: NgZone, | ||||
|     readonly elRef: ElementRef, | ||||
|     private stateService: StateService, | ||||
|   ) { | ||||
|     this.webGlEnabled = detectWebGL(); | ||||
|     this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); | ||||
|     this.searchSubscription = this.stateService.searchText$.subscribe((text) => { | ||||
|       this.searchText = text; | ||||
| @ -113,16 +119,17 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|     if (changes.overrideColor && this.scene) { | ||||
|       this.scene.setColorFunction(this.overrideColors); | ||||
|     } | ||||
|     if ((changes.filterFlags || changes.showFilters)) { | ||||
|     if ((changes.filterFlags || changes.showFilters || changes.filterMode)) { | ||||
|       this.setFilterFlags(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setFilterFlags(flags?: bigint | null): void { | ||||
|     this.activeFilterFlags = this.filterFlags || flags || null; | ||||
|   setFilterFlags(goggle?: ActiveFilter): void { | ||||
|     this.filterMode = goggle?.mode || this.filterMode; | ||||
|     this.activeFilterFlags = goggle?.filters ? toFlags(goggle.filters) : this.filterFlags; | ||||
|     if (this.scene) { | ||||
|       if (flags != null) { | ||||
|         this.scene.setColorFunction(this.getFilterColorFunction(flags)); | ||||
|       if (this.activeFilterFlags != null && this.filtersAvailable) { | ||||
|         this.scene.setColorFunction(this.getFilterColorFunction(this.activeFilterFlags)); | ||||
|       } else { | ||||
|         this.scene.setColorFunction(this.overrideColors); | ||||
|       } | ||||
| @ -156,7 +163,11 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
| 
 | ||||
|   // initialize the scene without any entry transition
 | ||||
|   setup(transactions: TransactionStripped[]): void { | ||||
|     this.filtersAvailable = transactions.reduce((flagSet, tx) => flagSet || tx.flags > 0, false); | ||||
|     const filtersAvailable = transactions.reduce((flagSet, tx) => flagSet || tx.flags > 0, false); | ||||
|     if (filtersAvailable !== this.filtersAvailable) { | ||||
|       this.setFilterFlags(); | ||||
|     } | ||||
|     this.filtersAvailable = filtersAvailable; | ||||
|     if (this.scene) { | ||||
|       this.scene.setup(transactions); | ||||
|       this.readyNextFrame = true; | ||||
| @ -499,11 +510,13 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|   } | ||||
| 
 | ||||
|   onTxClick(cssX: number, cssY: number, keyModifier: boolean = false) { | ||||
|     const x = cssX * window.devicePixelRatio; | ||||
|     const y = cssY * window.devicePixelRatio; | ||||
|     const selected = this.scene.getTxAt({ x, y }); | ||||
|     if (selected && selected.txid) { | ||||
|       this.txClickEvent.emit({ tx: selected, keyModifier }); | ||||
|     if (this.scene) { | ||||
|       const x = cssX * window.devicePixelRatio; | ||||
|       const y = cssY * window.devicePixelRatio; | ||||
|       const selected = this.scene.getTxAt({ x, y }); | ||||
|       if (selected && selected.txid) { | ||||
|         this.txClickEvent.emit({ tx: selected, keyModifier }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -523,7 +536,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
| 
 | ||||
|   getFilterColorFunction(flags: bigint): ((tx: TxView) => Color) { | ||||
|     return (tx: TxView) => { | ||||
|       if ((tx.bigintFlags & flags) === flags) { | ||||
|       if ((this.filterMode === 'and' && (tx.bigintFlags & flags) === flags) || (this.filterMode === 'or' && (flags === 0n || (tx.bigintFlags & flags) > 0n))) { | ||||
|         return defaultColorFunction(tx); | ||||
|       } else { | ||||
|         return defaultColorFunction( | ||||
|  | ||||
| @ -10,6 +10,7 @@ import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.in | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; | ||||
| import { ServicesApiServices } from '../../services/services-api.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block-preview', | ||||
| @ -42,7 +43,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { | ||||
|     public stateService: StateService, | ||||
|     private seoService: SeoService, | ||||
|     private openGraphService: OpenGraphService, | ||||
|     private apiService: ApiService | ||||
|     private apiService: ApiService, | ||||
|     private servicesApiService: ServicesApiServices, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit() { | ||||
| @ -134,7 +136,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { | ||||
|                   return of(transactions); | ||||
|                 }) | ||||
|               ), | ||||
|             this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.apiService.getAccelerationHistory$({ blockHash: block.id }) : of([]) | ||||
|             this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHash: block.id }) : of([]) | ||||
|           ]); | ||||
|         } | ||||
|       ), | ||||
|  | ||||
| @ -16,6 +16,7 @@ import { detectWebGL } from '../../shared/graphs.utils'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| import { PriceService, Price } from '../../services/price.service'; | ||||
| import { CacheService } from '../../services/cache.service'; | ||||
| import { ServicesApiServices } from '../../services/services-api.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-block', | ||||
| @ -103,6 +104,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|     private apiService: ApiService, | ||||
|     private priceService: PriceService, | ||||
|     private cacheService: CacheService, | ||||
|     private servicesApiService: ServicesApiServices, | ||||
|   ) { | ||||
|     this.webGlEnabled = detectWebGL(); | ||||
|   } | ||||
| @ -329,7 +331,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|                 return of(null); | ||||
|               }) | ||||
|             ), | ||||
|           this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.apiService.getAccelerationHistory$({ blockHash: block.id }) : of([]) | ||||
|           this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHash: block.id }) : of([]) | ||||
|         ]); | ||||
|       }) | ||||
|     ) | ||||
|  | ||||
| @ -26,7 +26,7 @@ | ||||
|             </div> | ||||
|             <ng-template #emptyfees> | ||||
|               <div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees"> | ||||
|                   | ||||
|                 <app-fee-rate unitClass=""></app-fee-rate> | ||||
|               </div> | ||||
|             </ng-template> | ||||
|             <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span" | ||||
| @ -37,7 +37,7 @@ | ||||
|             </div> | ||||
|             <ng-template #emptyfeespan> | ||||
|               <div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fee-span"> | ||||
|                   | ||||
|                 <app-fee-rate unitClass=""></app-fee-rate> | ||||
|               </div> | ||||
|             </ng-template> | ||||
|             <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="showMiningInfo" | ||||
|  | ||||
| @ -92,21 +92,18 @@ | ||||
|               <span class="skeleton-loader" style="max-width: 75px"></span> | ||||
|             </td> | ||||
|             <td *ngIf="isMempoolModule" class="pool text-left" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}"> | ||||
|               <span class="skeleton-loader" style="max-width: 125px"></span> | ||||
|               <span class="skeleton-loader" style="max-width: 150px"></span> | ||||
|             </td> | ||||
|             <td class="timestamp" *ngIf="!widget" [class]="isMempoolModule ? '' : 'legacy'"> | ||||
|               <span class="skeleton-loader" style="max-width: 150px"></span> | ||||
|             </td> | ||||
|             <td class="mined" *ngIf="!widget" [class]="isMempoolModule ? '' : 'legacy'"> | ||||
|               <span class="skeleton-loader" style="max-width: 125px"></span> | ||||
|             </td> | ||||
|             <td *ngIf="auditAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}"> | ||||
|               <span class="skeleton-loader" style="max-width: 75px"></span> | ||||
|             </td> | ||||
|             <td *ngIf="isMempoolModule" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}"> | ||||
|               <span class="skeleton-loader" style="max-width: 75px"></span> | ||||
|             </td> | ||||
|             <td *ngIf="isMempoolModule && !widget" class="fees text-right" [class]="isMempoolModule ? '' : 'legacy'"> | ||||
|             <td *ngIf="isMempoolModule && !auditAvailable || isMempoolModule && !widget" class="fees text-right" [class]="isMempoolModule ? '' : 'legacy'"> | ||||
|               <span class="skeleton-loader" style="max-width: 75px"></span> | ||||
|             </td> | ||||
|             <td *ngIf="auditAvailable && !widget" class="fee-delta" [class]="isMempoolModule ? '' : 'legacy'"> | ||||
|  | ||||
| @ -14,7 +14,7 @@ | ||||
|         </div> | ||||
|         <div class="item"> | ||||
|           <h5 class="card-title" i18n="difficulty-box.estimate">Estimate</h5> | ||||
|           <div *ngIf="epochData.remainingBlocks < 1870; else recentlyAdjusted" class="card-text" [ngStyle]="{'color': epochData.colorAdjustments}"> | ||||
|           <div class="card-text" [ngStyle]="{'color': epochData.colorAdjustments}"> | ||||
|             <span *ngIf="epochData.change > 0; else arrowDownDifficulty" > | ||||
|               <fa-icon class="retarget-sign" [icon]="['fas', 'caret-up']" [fixedWidth]="true"></fa-icon> | ||||
|             </span> | ||||
| @ -24,9 +24,6 @@ | ||||
|             {{ epochData.change | absolute | number: '1.2-2' }} | ||||
|             <span class="symbol">%</span> | ||||
|           </div> | ||||
|           <ng-template #recentlyAdjusted> | ||||
|             <div class="card-text">—</div> | ||||
|           </ng-template> | ||||
|           <div class="symbol"> | ||||
|             <span i18n="difficulty-box.previous">Previous</span>: | ||||
|             <span [ngStyle]="{'color': epochData.colorPreviousAdjustments}"> | ||||
| @ -49,13 +46,15 @@ | ||||
|         <div class="item" *ngIf="showHalving"> | ||||
|           <h5 class="card-title" i18n="difficulty-box.next-halving">Next Halving</h5> | ||||
|           <div class="card-text" i18n-ngbTooltip="mining.average-fee" [ngbTooltip]="halvingBlocksLeft" [tooltipContext]="{ epochData: epochData }" placement="bottom"> | ||||
|             <span>{{ timeUntilHalving | date }}</span> | ||||
|             <ng-container *ngTemplateOutlet="epochData.blocksUntilHalving === 1 ? blocksSingular : blocksPlural; context: {$implicit: epochData.blocksUntilHalving }"></ng-container> | ||||
|             <ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template> | ||||
|             <ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template> | ||||
|             <div class="symbol" *ngIf="blocksUntilHalving === 1; else approxTime"> | ||||
|               <app-time kind="until" [time]="epochData.timeAvg + now" [fastRender]="false" [fixedRender]="true" [precision]="1" minUnit="minute"></app-time> | ||||
|               <app-time kind="until" [time]="epochData.adjustedTimeAvg + now" [fastRender]="false" [fixedRender]="true" [precision]="1" minUnit="minute"></app-time> | ||||
|             </div> | ||||
|             <ng-template #approxTime> | ||||
|               <div class="symbol"> | ||||
|                 <app-time kind="until" [time]="timeUntilHalving" [fastRender]="false" [fixedRender]="true" [precision]="0" [numUnits]="2" [units]="['year', 'day', 'hour', 'minute']"></app-time> | ||||
|                 <span>{{ timeUntilHalving | date }}</span> | ||||
|               </div> | ||||
|             </ng-template> | ||||
|           </div> | ||||
|  | ||||
| @ -16,6 +16,7 @@ interface EpochProgress { | ||||
|   blocksUntilHalving: number; | ||||
|   timeUntilHalving: number; | ||||
|   timeAvg: number; | ||||
|   adjustedTimeAvg: number; | ||||
| } | ||||
| 
 | ||||
| @Component({ | ||||
| @ -85,6 +86,7 @@ export class DifficultyMiningComponent implements OnInit { | ||||
|           blocksUntilHalving: this.blocksUntilHalving, | ||||
|           timeUntilHalving: this.timeUntilHalving, | ||||
|           timeAvg: da.timeAvg, | ||||
|           adjustedTimeAvg: da.adjustedTimeAvg, | ||||
|         }; | ||||
|         return data; | ||||
|       }) | ||||
|  | ||||
| @ -42,7 +42,7 @@ | ||||
|             <div class="symbol" i18n="difficulty-box.average-block-time">Average block time</div> | ||||
|           </div> | ||||
|           <div class="item"> | ||||
|             <div *ngIf="epochData.remainingBlocks < 1870; else recentlyAdjusted" class="card-text bigger" [ngStyle]="{'color': epochData.colorAdjustments}"> | ||||
|             <div class="card-text bigger" [ngStyle]="{'color': epochData.colorAdjustments}"> | ||||
|               <span *ngIf="epochData.change > 0; else arrowDownDifficulty" > | ||||
|                 <fa-icon class="retarget-sign" [icon]="['fas', 'caret-up']" [fixedWidth]="true"></fa-icon> | ||||
|               </span> | ||||
| @ -52,9 +52,6 @@ | ||||
|               {{ epochData.change | absolute | number: '1.2-2' }} | ||||
|               <span class="symbol">%</span> | ||||
|             </div> | ||||
|             <ng-template #recentlyAdjusted> | ||||
|               <div class="card-text">—</div> | ||||
|             </ng-template> | ||||
|             <div class="symbol"> | ||||
|               <span i18n="difficulty-box.previous">Previous</span>: | ||||
|               <span [ngStyle]="{'color': epochData.colorPreviousAdjustments}"> | ||||
|  | ||||
| @ -19,6 +19,7 @@ interface EpochProgress { | ||||
|   blocksUntilHalving: number; | ||||
|   timeUntilHalving: number; | ||||
|   timeAvg: number; | ||||
|   adjustedTimeAvg: number; | ||||
| } | ||||
| 
 | ||||
| type BlockStatus = 'mined' | 'behind' | 'ahead' | 'next' | 'remaining'; | ||||
| @ -153,6 +154,7 @@ export class DifficultyComponent implements OnInit { | ||||
|           blocksUntilHalving, | ||||
|           timeUntilHalving, | ||||
|           timeAvg: da.timeAvg, | ||||
|           adjustedTimeAvg: da.adjustedTimeAvg, | ||||
|         }; | ||||
|         return data; | ||||
|       }) | ||||
|  | ||||
| @ -54,7 +54,7 @@ | ||||
|     </form> | ||||
|   </div> | ||||
| 
 | ||||
|   <div [class]="!widget ? 'chart' : 'chart-widget'" echarts [initOpts]="chartInitOptions" [options]="chartOptions" | ||||
|   <div [class]="!widget ? 'chart' : 'chart-widget'" [style]="{ height: widget ? ((height + 20) + 'px') : null}" echarts [initOpts]="chartInitOptions" [options]="chartOptions" | ||||
|     (chartInit)="onChartInit($event)"> | ||||
|   </div> | ||||
|   <div class="text-center loadingGraphs" *ngIf="isLoading"> | ||||
|  | ||||
| @ -57,8 +57,6 @@ | ||||
| } | ||||
| .chart-widget { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   height: 240px; | ||||
| } | ||||
| 
 | ||||
| .pool-distribution { | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; | ||||
| import { echarts, EChartsOption } from '../../graphs/echarts'; | ||||
| import { merge, Observable, of } from 'rxjs'; | ||||
| import { combineLatest, fromEvent, merge, Observable, of } from 'rxjs'; | ||||
| import { map, mergeMap, share, startWith, switchMap, tap } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| @ -31,6 +31,7 @@ import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| export class HashrateChartComponent implements OnInit { | ||||
|   @Input() tableOnly = false; | ||||
|   @Input() widget = false; | ||||
|   @Input() height: number = 300; | ||||
|   @Input() right: number | string = 45; | ||||
|   @Input() left: number | string = 75; | ||||
| 
 | ||||
| @ -86,28 +87,32 @@ export class HashrateChartComponent implements OnInit { | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|     this.hashrateObservable$ = merge( | ||||
|       this.radioGroupForm.get('dateSpan').valueChanges | ||||
|         .pipe( | ||||
|           startWith(this.radioGroupForm.controls.dateSpan.value), | ||||
|           switchMap((timespan) => { | ||||
|             if (!this.widget && !firstRun) { | ||||
|               this.storageService.setValue('miningWindowPreference', timespan); | ||||
|             } | ||||
|             this.timespan = timespan; | ||||
|             firstRun = false; | ||||
|             this.miningWindowPreference = timespan; | ||||
|             this.isLoading = true; | ||||
|             return this.apiService.getHistoricalHashrate$(this.timespan); | ||||
|           }) | ||||
|         ), | ||||
|         this.stateService.chainTip$ | ||||
|     this.hashrateObservable$ = combineLatest( | ||||
|         merge( | ||||
|         this.radioGroupForm.get('dateSpan').valueChanges | ||||
|           .pipe( | ||||
|             switchMap(() => { | ||||
|             startWith(this.radioGroupForm.controls.dateSpan.value), | ||||
|             switchMap((timespan) => { | ||||
|               if (!this.widget && !firstRun) { | ||||
|                 this.storageService.setValue('miningWindowPreference', timespan); | ||||
|               } | ||||
|               this.timespan = timespan; | ||||
|               firstRun = false; | ||||
|               this.miningWindowPreference = timespan; | ||||
|               this.isLoading = true; | ||||
|               return this.apiService.getHistoricalHashrate$(this.timespan); | ||||
|             }) | ||||
|           ) | ||||
|           ), | ||||
|           this.stateService.chainTip$ | ||||
|             .pipe( | ||||
|               switchMap(() => { | ||||
|                 return this.apiService.getHistoricalHashrate$(this.timespan); | ||||
|               }) | ||||
|             ) | ||||
|         ), | ||||
|         fromEvent(window, 'resize').pipe(startWith(null)), | ||||
|       ).pipe( | ||||
|         map(([response, _]) => response), | ||||
|         tap((response: any) => { | ||||
|           const data = response.body; | ||||
| 
 | ||||
| @ -221,6 +226,7 @@ export class HashrateChartComponent implements OnInit { | ||||
|         ]), | ||||
|       ], | ||||
|       grid: { | ||||
|         height: (this.widget && this.height) ? this.height - 30 : undefined, | ||||
|         top: this.widget ? 20 : 40, | ||||
|         bottom: this.widget ? 30 : 70, | ||||
|         right: this.right, | ||||
|  | ||||
| @ -11,6 +11,13 @@ import { MiningService } from '../../services/mining.service'; | ||||
| import { download } from '../../shared/graphs.utils'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| 
 | ||||
| interface Hashrate { | ||||
|   timestamp: number; | ||||
|   avgHashRate: number; | ||||
|   share: number; | ||||
|   poolName: string; | ||||
| } | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-hashrate-chart-pools', | ||||
|   templateUrl: './hashrate-chart-pools.component.html', | ||||
| @ -32,6 +39,7 @@ export class HashrateChartPoolsComponent implements OnInit { | ||||
|   miningWindowPreference: string; | ||||
|   radioGroupForm: UntypedFormGroup; | ||||
| 
 | ||||
|   hashrates: Hashrate[]; | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
|     renderer: 'svg', | ||||
| @ -87,56 +95,9 @@ export class HashrateChartPoolsComponent implements OnInit { | ||||
|           return this.apiService.getHistoricalPoolsHashrate$(timespan) | ||||
|             .pipe( | ||||
|               tap((response) => { | ||||
|                 const hashrates = response.body; | ||||
|                 this.hashrates = response.body; | ||||
|                 // Prepare series (group all hashrates data point by pool)
 | ||||
|                 const grouped = {}; | ||||
|                 for (const hashrate of hashrates) { | ||||
|                   if (!grouped.hasOwnProperty(hashrate.poolName)) { | ||||
|                     grouped[hashrate.poolName] = []; | ||||
|                   } | ||||
|                   grouped[hashrate.poolName].push(hashrate); | ||||
|                 } | ||||
| 
 | ||||
|                 const series = []; | ||||
|                 const legends = []; | ||||
|                 for (const name in grouped) { | ||||
|                   series.push({ | ||||
|                     zlevel: 0, | ||||
|                     stack: 'Total', | ||||
|                     name: name, | ||||
|                     showSymbol: false, | ||||
|                     symbol: 'none', | ||||
|                     data: grouped[name].map((val) => [val.timestamp * 1000, val.share * 100]), | ||||
|                     type: 'line', | ||||
|                     lineStyle: { width: 0 }, | ||||
|                     areaStyle: { opacity: 1 }, | ||||
|                     smooth: true, | ||||
|                     color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()], | ||||
|                     emphasis: { | ||||
|                       disabled: true, | ||||
|                       scale: false, | ||||
|                     }, | ||||
|                   }); | ||||
| 
 | ||||
|                   legends.push({ | ||||
|                     name: name, | ||||
|                     inactiveColor: 'rgb(110, 112, 121)', | ||||
|                     textStyle: { | ||||
|                       color: 'white', | ||||
|                     }, | ||||
|                     icon: 'roundRect', | ||||
|                     itemStyle: { | ||||
|                       color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()], | ||||
|                     }, | ||||
|                   }); | ||||
|                 } | ||||
| 
 | ||||
|                 this.prepareChartOptions({ | ||||
|                   legends: legends, | ||||
|                   series: series, | ||||
|                 }); | ||||
|                 this.isLoading = false; | ||||
| 
 | ||||
|                 const series = this.applyHashrates(); | ||||
|                 if (series.length === 0) { | ||||
|                   this.cd.markForCheck(); | ||||
|                   throw new Error(); | ||||
| @ -156,6 +117,77 @@ export class HashrateChartPoolsComponent implements OnInit { | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   applyHashrates(): any[] { | ||||
|     const times: { [time: number]: { hashrates: { [pool: string]: Hashrate } } } = {}; | ||||
|     const pools = {}; | ||||
|     for (const hashrate of this.hashrates) { | ||||
|       if (!times[hashrate.timestamp]) { | ||||
|         times[hashrate.timestamp] = { hashrates: {} }; | ||||
|       } | ||||
|       times[hashrate.timestamp].hashrates[hashrate.poolName] = hashrate; | ||||
|       if (!pools[hashrate.poolName]) { | ||||
|         pools[hashrate.poolName] = true; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const sortedTimes = Object.keys(times).sort((a,b) => parseInt(a) - parseInt(b)).map(time => ({ time: parseInt(time), hashrates: times[time].hashrates })); | ||||
|     const lastHashrates = sortedTimes[sortedTimes.length - 1].hashrates; | ||||
|     const sortedPools = Object.keys(pools).sort((a,b) => { | ||||
|       if (lastHashrates[b]?.share ?? lastHashrates[a]?.share ?? false) { | ||||
|         // sort by descending share of hashrate in latest period
 | ||||
|         return (lastHashrates[b]?.share || 0) - (lastHashrates[a]?.share || 0); | ||||
|       } else { | ||||
|         // tiebreak by pool name
 | ||||
|         b < a; | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     const series = []; | ||||
|     const legends = []; | ||||
|     for (const name of sortedPools) { | ||||
|       const data = sortedTimes.map(({ time, hashrates }) => { | ||||
|         return [time * 1000, (hashrates[name]?.share || 0) * 100]; | ||||
|       }); | ||||
|       series.push({ | ||||
|         zlevel: 0, | ||||
|         stack: 'Total', | ||||
|         name: name, | ||||
|         showSymbol: false, | ||||
|         symbol: 'none', | ||||
|         data, | ||||
|         type: 'line', | ||||
|         lineStyle: { width: 0 }, | ||||
|         areaStyle: { opacity: 1 }, | ||||
|         smooth: true, | ||||
|         color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()], | ||||
|         emphasis: { | ||||
|           disabled: true, | ||||
|           scale: false, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       legends.push({ | ||||
|         name: name, | ||||
|         inactiveColor: 'rgb(110, 112, 121)', | ||||
|         textStyle: { | ||||
|           color: 'white', | ||||
|         }, | ||||
|         icon: 'roundRect', | ||||
|         itemStyle: { | ||||
|           color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()], | ||||
|         }, | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     this.prepareChartOptions({ | ||||
|       legends: legends, | ||||
|       series: series, | ||||
|     }); | ||||
|     this.isLoading = false; | ||||
| 
 | ||||
|     return series; | ||||
|   } | ||||
| 
 | ||||
|   prepareChartOptions(data) { | ||||
|     let title: object; | ||||
|     if (data.series.length === 0) { | ||||
| @ -256,6 +288,7 @@ export class HashrateChartPoolsComponent implements OnInit { | ||||
|         }, | ||||
|       }], | ||||
|     }; | ||||
|     this.cd.markForCheck(); | ||||
|   } | ||||
| 
 | ||||
|   onChartInit(ec) { | ||||
|  | ||||
| @ -18,16 +18,15 @@ import { EChartsOption } from '../../graphs/echarts'; | ||||
| }) | ||||
| export class LbtcPegsGraphComponent implements OnInit, OnChanges { | ||||
|   @Input() data: any; | ||||
|   @Input() height: number | string = '320'; | ||||
|   pegsChartOptions: EChartsOption; | ||||
| 
 | ||||
|   height: number | string = '200'; | ||||
|   right: number | string = '10'; | ||||
|   top: number | string = '20'; | ||||
|   left: number | string = '50'; | ||||
|   template: ('widget' | 'advanced') = 'widget'; | ||||
|   isLoading = true; | ||||
| 
 | ||||
|   pegsChartOption: EChartsOption = {}; | ||||
|   pegsChartInitOption = { | ||||
|     renderer: 'svg' | ||||
|   }; | ||||
| @ -41,20 +40,24 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { | ||||
|   } | ||||
| 
 | ||||
|   ngOnChanges() { | ||||
|     if (!this.data) { | ||||
|     if (!this.data?.liquidPegs) { | ||||
|       return; | ||||
|     } | ||||
|     this.pegsChartOptions = this.createChartOptions(this.data.series, this.data.labels); | ||||
|     if (!this.data.liquidReserves) { | ||||
|       this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels); | ||||
|     } else { | ||||
|       this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels, this.data.liquidReserves.series); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   rendered() { | ||||
|     if (!this.data) { | ||||
|     if (!this.data.liquidPegs) { | ||||
|       return; | ||||
|     } | ||||
|     this.isLoading = false; | ||||
|   } | ||||
| 
 | ||||
|   createChartOptions(series: number[], labels: string[]): EChartsOption { | ||||
|   createChartOptions(pegSeries: number[], labels: string[], reservesSeries?: number[],): EChartsOption { | ||||
|     return { | ||||
|       grid: { | ||||
|         height: this.height, | ||||
| @ -99,17 +102,18 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { | ||||
|           type: 'line', | ||||
|         }, | ||||
|         formatter: (params: any) => { | ||||
|           const colorSpan = (color: string) => `<span class="indicator" style="background-color: #116761;"></span>`; | ||||
|           const colorSpan = (color: string) => `<span class="indicator" style="background-color: ${color};"></span>`; | ||||
|           let itemFormatted = '<div class="title">' + params[0].axisValue + '</div>'; | ||||
|           params.map((item: any, index: number) => { | ||||
|           for (let index = params.length - 1; index >= 0; index--) { | ||||
|             const item = params[index]; | ||||
|             if (index < 26) { | ||||
|               itemFormatted += `<div class="item">
 | ||||
|                 <div class="indicator-container">${colorSpan(item.color)}</div> | ||||
|                 <div class="grow"></div> | ||||
|                 <div class="value">${formatNumber(item.value, this.locale, '1.2-2')} <span class="symbol">L-BTC</span></div> | ||||
|                 <div style="margin-right: 5px"></div> | ||||
|                 <div class="value">${formatNumber(item.value, this.locale, '1.2-2')} <span class="symbol">${item.seriesName}</span></div> | ||||
|               </div>`;
 | ||||
|             } | ||||
|           }); | ||||
|           } | ||||
|           return `<div class="tx-wrapper-tooltip-chart ${(this.template === 'advanced') ? 'tx-wrapper-tooltip-chart-advanced' : ''}">${itemFormatted}</div>`; | ||||
|         } | ||||
|       }, | ||||
| @ -138,20 +142,34 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { | ||||
|       }, | ||||
|       series: [ | ||||
|         { | ||||
|           data: series, | ||||
|           data: pegSeries, | ||||
|           name: 'L-BTC', | ||||
|           color: '#116761', | ||||
|           type: 'line', | ||||
|           stack: 'total', | ||||
|           smooth: false, | ||||
|           smooth: true, | ||||
|           showSymbol: false, | ||||
|           areaStyle: { | ||||
|             opacity: 0.2, | ||||
|             color: '#116761', | ||||
|           }, | ||||
|           lineStyle: { | ||||
|             width: 3, | ||||
|             width: 2, | ||||
|             color: '#116761', | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           data: reservesSeries, | ||||
|           name: 'BTC', | ||||
|           color: '#EA983B', | ||||
|           type: 'line', | ||||
|           smooth: true, | ||||
|           showSymbol: false, | ||||
|           lineStyle: { | ||||
|             width: 2, | ||||
|             color: '#EA983B', | ||||
|           }, | ||||
|         }, | ||||
|       ], | ||||
|     }; | ||||
|   } | ||||
|  | ||||
| @ -78,7 +78,7 @@ | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-assets"> | ||||
|         <a class="nav-link" [routerLink]="['/assets' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'database']" [fixedWidth]="true" i18n-title="master-page.assets" title="Assets"></fa-icon></a> | ||||
|       </li> | ||||
|       <li [hidden]="isMobile" class="nav-item mr-2" routerLinkActive="active" id="btn-docs"> | ||||
|       <li class="nav-item mr-2" routerLinkActive="active" id="btn-docs"> | ||||
|         <a class="nav-link" [routerLink]="['/docs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="master-page.docs" title="Docs"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-about"> | ||||
|  | ||||
| @ -23,6 +23,11 @@ li.nav-item { | ||||
|   margin: auto 10px; | ||||
|   padding-left: 10px; | ||||
|   padding-right: 10px; | ||||
|   @media (max-width: 429px) { | ||||
|     margin: auto 5px; | ||||
|     padding-left: 6px; | ||||
|     padding-right: 6px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media (min-width: 992px) { | ||||
|  | ||||
| @ -0,0 +1,72 @@ | ||||
| <div [ngClass]="{'widget': widget}"> | ||||
| 
 | ||||
|   <div class="clearfix"></div> | ||||
| 
 | ||||
|   <div style="min-height: 295px"> | ||||
|     <table class="table table-borderless"> | ||||
|       <thead style="vertical-align: middle;"> | ||||
|         <th class="address text-left" [ngClass]="{'widget': widget}" i18n="shared.address">Address</th> | ||||
|         <th class="amount text-right" [ngClass]="{'widget': widget}" i18n="address.balance">Balance</th> | ||||
|       </thead> | ||||
|       <tbody *ngIf="federationAddresses$ | async as addresses; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''"> | ||||
|         <ng-container *ngIf="widget; else regularRows"> | ||||
|           <tr *ngFor="let address of addresses | slice:0:5"> | ||||
|             <td class="address text-left widget"> | ||||
|               <a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + address.bitcoinaddress }}" target="_blank" style="color:#b86d12"> | ||||
|                 <app-truncate [text]="address.bitcoinaddress" [lastChars]="6"></app-truncate> | ||||
|               </a> | ||||
|             </td> | ||||
|             <td class="amount text-right widget"> | ||||
|               <app-amount [satoshis]="+address.balance" [noFiat]="true" [forceBtc]="true"></app-amount> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-container> | ||||
|         <ng-template #regularRows> | ||||
|           <tr *ngFor="let address of addresses | slice:(page - 1) * pageSize:page * pageSize"> | ||||
|             <td class="address text-left"> | ||||
|               <a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + address.bitcoinaddress }}" target="_blank" style="color:#b86d12"> | ||||
|                 <app-truncate [text]="address.bitcoinaddress" [lastChars]="6"></app-truncate> | ||||
|               </a> | ||||
|             </td> | ||||
|             <td class="amount text-right"> | ||||
|               <app-amount [satoshis]="+address.balance" [noFiat]="true" [forceBtc]="true"></app-amount> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-template> | ||||
|       </tbody> | ||||
|       <ng-template #skeleton> | ||||
|         <tbody *ngIf="widget; else regularRowsSkeleton"> | ||||
|           <tr *ngFor="let item of skeletonLines"> | ||||
|             <td class="address text-left widget"> | ||||
|               <span class="skeleton-loader" style="max-width: 400px"></span> | ||||
|             </td> | ||||
|             <td class="amount text-right widget"> | ||||
|               <span class="skeleton-loader" style="max-width: 350px"></span> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </tbody> | ||||
|         <ng-template #regularRowsSkeleton> | ||||
|           <tr *ngFor="let item of skeletonLines"> | ||||
|             <td class="address text-left"> | ||||
|               <span class="skeleton-loader" style="max-width: 600px"></span> | ||||
|             </td> | ||||
|             <td class="amount text-right"> | ||||
|               <span class="skeleton-loader" style="max-width: 400px"></span> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-template> | ||||
|       </ng-template> | ||||
|     </table> | ||||
| 
 | ||||
|     <ngb-pagination *ngIf="!widget && federationAddresses$ | async as addresses" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''" | ||||
|       [collectionSize]="addresses.length" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page" | ||||
|       (pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false"> | ||||
|     </ngb-pagination> | ||||
| 
 | ||||
|     <ng-template [ngIf]="!widget"> | ||||
|       <div class="clearfix"></div> | ||||
|       <br> | ||||
|     </ng-template> | ||||
|   </div> | ||||
|    | ||||
| </div> | ||||
| @ -0,0 +1,48 @@ | ||||
| .spinner-border { | ||||
|   height: 25px; | ||||
|   width: 25px; | ||||
|   margin-top: 13px; | ||||
| } | ||||
| 
 | ||||
| tr, td, th { | ||||
|   border: 0px; | ||||
|   padding-top: 0.65rem; | ||||
|   padding-bottom: 0.6rem; | ||||
|   padding-right: 2rem; | ||||
|   .widget &.widget { | ||||
|     padding-right: 1rem; | ||||
|     @media (max-width: 510px) { | ||||
|       padding-right: 0.5rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .clear-link { | ||||
|   color: white; | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| 
 | ||||
| .progress { | ||||
|   background-color: #2d3348; | ||||
| } | ||||
| 
 | ||||
| .address { | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   max-width: 160px; | ||||
| } | ||||
| .address.widget { | ||||
|   width: 60%; | ||||
| } | ||||
| 
 | ||||
| .amount { | ||||
|   width: 25%; | ||||
| } | ||||
| .amount.widget { | ||||
|   width: 40%; | ||||
| } | ||||
| @ -0,0 +1,109 @@ | ||||
| import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core'; | ||||
| import { Observable, Subject, combineLatest, of, timer } from 'rxjs'; | ||||
| import { delayWhen, filter, map, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../../services/api.service'; | ||||
| import { Env, StateService } from '../../../services/state.service'; | ||||
| import { AuditStatus, CurrentPegs, FederationAddress } from '../../../interfaces/node-api.interface'; | ||||
| import { WebsocketService } from '../../../services/websocket.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-federation-addresses-list', | ||||
|   templateUrl: './federation-addresses-list.component.html', | ||||
|   styleUrls: ['./federation-addresses-list.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class FederationAddressesListComponent implements OnInit { | ||||
|   @Input() widget: boolean = false; | ||||
|   @Input() federationAddresses$: Observable<FederationAddress[]>; | ||||
| 
 | ||||
|   env: Env; | ||||
|   isLoading = true; | ||||
|   page = 1; | ||||
|   pageSize = 15; | ||||
|   maxSize = window.innerWidth <= 767.98 ? 3 : 5; | ||||
|   skeletonLines: number[] = []; | ||||
|   auditStatus$: Observable<AuditStatus>; | ||||
|   auditUpdated$: Observable<boolean>; | ||||
|   lastReservesBlockUpdate: number = 0; | ||||
|   currentPeg$: Observable<CurrentPegs>; | ||||
|   lastPegBlockUpdate: number = 0; | ||||
|   lastPegAmount: string = ''; | ||||
|   isLoad: boolean = true; | ||||
| 
 | ||||
|   private destroy$ = new Subject(); | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     public stateService: StateService, | ||||
|     private websocketService: WebsocketService | ||||
|   ) { | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.isLoading = !this.widget; | ||||
|     this.env = this.stateService.env; | ||||
|     this.skeletonLines = this.widget === true ? [...Array(5).keys()] : [...Array(15).keys()]; | ||||
|     if (!this.widget) { | ||||
|       this.websocketService.want(['blocks']); | ||||
|       this.auditStatus$ = this.stateService.blocks$.pipe( | ||||
|         takeUntil(this.destroy$), | ||||
|         throttleTime(40000), | ||||
|         delayWhen(_ => this.isLoad ? timer(0) : timer(2000)), | ||||
|         tap(() => this.isLoad = false), | ||||
|         switchMap(() => this.apiService.federationAuditSynced$()), | ||||
|         shareReplay(1) | ||||
|       ); | ||||
| 
 | ||||
|       this.currentPeg$ = this.auditStatus$.pipe( | ||||
|         filter(auditStatus => auditStatus.isAuditSynced === true), | ||||
|         switchMap(_ => | ||||
|           this.apiService.liquidPegs$().pipe( | ||||
|             filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate), | ||||
|             tap((currentPegs) => { | ||||
|               this.lastPegBlockUpdate = currentPegs.lastBlockUpdate; | ||||
|             }) | ||||
|           ) | ||||
|         ), | ||||
|         share() | ||||
|       ); | ||||
| 
 | ||||
|       this.auditUpdated$ = combineLatest([ | ||||
|         this.auditStatus$, | ||||
|         this.currentPeg$ | ||||
|       ]).pipe( | ||||
|         filter(([auditStatus, _]) => auditStatus.isAuditSynced === true), | ||||
|         map(([auditStatus, currentPeg]) => ({ | ||||
|           lastBlockAudit: auditStatus.lastBlockAudit, | ||||
|           currentPegAmount: currentPeg.amount | ||||
|         })), | ||||
|         switchMap(({ lastBlockAudit, currentPegAmount }) => { | ||||
|           const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate; | ||||
|           const amountCheck = currentPegAmount !== this.lastPegAmount; | ||||
|           this.lastReservesBlockUpdate = lastBlockAudit; | ||||
|           this.lastPegAmount = currentPegAmount; | ||||
|           return of(blockAuditCheck || amountCheck); | ||||
|         }), | ||||
|         share() | ||||
|       ); | ||||
| 
 | ||||
|       this.federationAddresses$ = this.auditUpdated$.pipe( | ||||
|         filter(auditUpdated => auditUpdated === true), | ||||
|         throttleTime(40000), | ||||
|         switchMap(_ => this.apiService.federationAddresses$()), | ||||
|         tap(_ => this.isLoading = false), | ||||
|         share() | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     this.destroy$.next(1); | ||||
|     this.destroy$.complete(); | ||||
|   } | ||||
| 
 | ||||
|   pageChange(page: number): void { | ||||
|     this.page = page; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,31 @@ | ||||
| <div *ngIf="(federationWalletStats$ | async) as federationWalletStats; else loadingData"> | ||||
|   <div class="fee-estimation-container"> | ||||
|     <div class="item"> | ||||
|       <a class="title-link" [routerLink]="['/audit/wallet/addresses' | relativeUrl]"> | ||||
|         <h5 class="card-title"><ng-container i18n="liquid.federation-wallet">Liquid Federation Wallet</ng-container> <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5> | ||||
|       </a> | ||||
|       <div class="card-text"> | ||||
|         <div class="fee-text">{{ federationWalletStats.address_count }} <span i18n="shared.addresses">addresses</span></div> | ||||
|         <div class="fiat">{{ federationWalletStats.utxo_count }} <span i18n="shared.utxos">UTXOs</span></div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| 
 | ||||
| <ng-template #loadingData> | ||||
|   <div class="fee-estimation-container loading-container"> | ||||
|     <div class="item"> | ||||
|       <a class="title-link" [routerLink]="['/audit/wallet/addresses' | relativeUrl]"> | ||||
|         <h5 class="card-title"><ng-container i18n="liquid.federation-wallet">Liquid Federation Wallet</ng-container> <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5> | ||||
|       </a> | ||||
|       <div class="card-text"> | ||||
|         <div class="skeleton-loader"></div> | ||||
|         <div class="skeleton-loader"></div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </ng-template> | ||||
| 
 | ||||
| <ng-template #loadingSkeleton> | ||||
|   <div class="skeleton-loader skeleton-loader-transactions" style="margin-top: 8px; margin-bottom: 8px;"></div> | ||||
| </ng-template> | ||||
| @ -0,0 +1,73 @@ | ||||
| .fee-estimation-container { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   margin-bottom: 10px; | ||||
|   @media (min-width: 376px) { | ||||
|     flex-direction: row; | ||||
|   }   | ||||
|   .item { | ||||
|     max-width: 300px; | ||||
|     margin: 0; | ||||
|     width: -webkit-fill-available; | ||||
|     @media (min-width: 376px) { | ||||
|       margin: 0 auto 0px; | ||||
|     } | ||||
| 
 | ||||
|     .card-title { | ||||
|       margin: 0; | ||||
|       color: #4a68b9; | ||||
|       font-size: 10px; | ||||
|       font-size: 1rem; | ||||
|       white-space: nowrap; | ||||
|     } | ||||
| 
 | ||||
|     .card-text { | ||||
|       padding-top: 9px; | ||||
|       font-size: 22px; | ||||
|       span { | ||||
|         font-size: 11px; | ||||
|         position: relative; | ||||
|         top: -2px; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .card-text span { | ||||
|       color: #ffffff66; | ||||
|       font-size: 12px; | ||||
|       top: 0px; | ||||
|     } | ||||
|     .fee-text{ | ||||
|       border-bottom: 1px solid #ffffff1c; | ||||
|       width: fit-content; | ||||
|       margin: auto; | ||||
|       line-height: 1.45; | ||||
|       padding: 0px 2px; | ||||
|     } | ||||
|     .fiat { | ||||
|       display: block; | ||||
|       font-size: 14px !important; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .card-text { | ||||
|   .skeleton-loader { | ||||
|     width: 100%; | ||||
|     display: block; | ||||
|     &:first-child { | ||||
|       max-width: 90px; | ||||
|       margin: 15px auto 3px; | ||||
|     } | ||||
|     &:last-child { | ||||
|       margin: 10px auto 3px; | ||||
|       max-width: 55px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .title-link, .title-link:hover, .title-link:focus, .title-link:active { | ||||
|   display: block; | ||||
|   margin-bottom: 4px; | ||||
|   text-decoration: none; | ||||
|   color: inherit; | ||||
| } | ||||
| @ -0,0 +1,31 @@ | ||||
| import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; | ||||
| import { Observable, combineLatest, map, of } from 'rxjs'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-federation-addresses-stats', | ||||
|   templateUrl: './federation-addresses-stats.component.html', | ||||
|   styleUrls: ['./federation-addresses-stats.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class FederationAddressesStatsComponent implements OnInit { | ||||
|   @Input() federationAddressesNumber$: Observable<number>; | ||||
|   @Input() federationUtxosNumber$: Observable<number>; | ||||
|   federationWalletStats$: Observable<any>; | ||||
| 
 | ||||
|   constructor() { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.federationWalletStats$ = combineLatest([ | ||||
|       this.federationAddressesNumber$ ?? of(undefined), | ||||
|       this.federationUtxosNumber$ ?? of(undefined) | ||||
|     ]).pipe( | ||||
|       map(([address_count, utxo_count]) => { | ||||
|         if (address_count === undefined || utxo_count === undefined) { | ||||
|           return undefined; | ||||
|         } | ||||
|         return { address_count, utxo_count} | ||||
|       }) | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,109 @@ | ||||
| <div [ngClass]="{'widget': widget}"> | ||||
| 
 | ||||
|   <div class="clearfix"></div> | ||||
|    | ||||
|   <div style="min-height: 295px"> | ||||
|     <table class="table table-borderless"> | ||||
|       <thead style="vertical-align: middle;"> | ||||
|         <th class="txid text-left" [ngClass]="{'widget': widget}" i18n="transaction.output">Output</th> | ||||
|         <th class="address text-left" *ngIf="!widget" i18n="shared.address">Address</th> | ||||
|         <th class="amount text-right" [ngClass]="{'widget': widget}" i18n="shared.amount">Amount</th> | ||||
|         <th class="pegin text-left" *ngIf="!widget" i18n="liquid.related-peg-in">Related Peg-In</th> | ||||
|         <th class="timestamp text-left" i18n="shared.date" [ngClass]="{'widget': widget}">Date</th> | ||||
|       </thead> | ||||
|       <tbody *ngIf="federationUtxos$ | async as utxos; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''"> | ||||
|         <ng-container *ngIf="widget; else regularRows"> | ||||
|           <tr *ngFor="let utxo of utxos | slice:0:6"> | ||||
|             <td class="txid text-left widget"> | ||||
|               <a href="{{ env.MEMPOOL_WEBSITE_URL + '/tx/' + utxo.txid + ':' + utxo.txindex }}" target="_blank" style="color:#b86d12"> | ||||
|                 <app-truncate [text]="utxo.txid + ':' + utxo.txindex" [lastChars]="6"></app-truncate> | ||||
|               </a> | ||||
|             </td> | ||||
|             <td class="amount text-right widget"> | ||||
|               <app-amount [satoshis]="utxo.amount" [noFiat]="true" [forceBtc]="true"></app-amount> | ||||
|             </td> | ||||
|             <td class="timestamp text-left widget"> | ||||
|               <app-time kind="since" [time]="utxo.blocktime"></app-time> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-container> | ||||
|         <ng-template #regularRows> | ||||
|           <tr *ngFor="let utxo of utxos | slice:(page - 1) * pageSize:page * pageSize"> | ||||
|             <td class="txid text-left"> | ||||
|               <a href="{{ env.MEMPOOL_WEBSITE_URL + '/tx/' + utxo.txid + ':' + utxo.txindex }}" target="_blank" style="color:#b86d12"> | ||||
|                 <app-truncate [text]="utxo.txid + ':' + utxo.txindex" [lastChars]="6"></app-truncate> | ||||
|               </a> | ||||
|             </td> | ||||
|             <td class="address text-left"> | ||||
|               <a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + utxo.bitcoinaddress }}" target="_blank" style="color:#b86d12"> | ||||
|                 <app-truncate [text]="utxo.bitcoinaddress" [lastChars]="6"></app-truncate> | ||||
|               </a> | ||||
|             </td> | ||||
|             <td class="amount text-right"> | ||||
|               <app-amount [satoshis]="utxo.amount" [noFiat]="true" [forceBtc]="true"></app-amount> | ||||
|             </td> | ||||
|             <td class="pegin text-left"> | ||||
|               <ng-container *ngIf="utxo.pegtxid; else noPeginMessage"> | ||||
|                 <a [routerLink]="['/tx' | relativeUrl, utxo.pegtxid]" [fragment]="'vin=' + utxo.pegindex"> | ||||
|                   <app-truncate [text]="utxo.pegtxid + ':' + utxo.pegindex" [lastChars]="6"></app-truncate> | ||||
|                 </a> | ||||
|               </ng-container> | ||||
|               <ng-template #noPeginMessage> | ||||
|                 <i><span class="text-muted" i18n="liquid.change-output">Change output</span></i> | ||||
|               </ng-template> | ||||
|             </td> | ||||
|             <td class="timestamp text-left"> | ||||
|               ‎{{ utxo.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }} | ||||
|               <div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="utxo.blocktime"></app-time>)</i></div> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-template> | ||||
|       </tbody> | ||||
|       <ng-template #skeleton> | ||||
|         <tbody *ngIf="widget; else regularRowsSkeleton"> | ||||
|           <tr *ngFor="let item of skeletonLines"> | ||||
|             <td class="txid text-left widget"> | ||||
|               <span class="skeleton-loader" style="max-width: 400px"></span> | ||||
|             </td> | ||||
|             <td class="amount text-right widget"> | ||||
|               <span class="skeleton-loader" style="max-width: 300px"></span> | ||||
|             </td> | ||||
|             <td class="timestamp text-left widget"> | ||||
|               <span class="skeleton-loader" style="max-width: 300px"></span> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </tbody> | ||||
|         <ng-template #regularRowsSkeleton> | ||||
|           <tr *ngFor="let item of skeletonLines"> | ||||
|             <td class="txid text-left"> | ||||
|               <span class="skeleton-loader" style="max-width: 300px"></span> | ||||
|             </td> | ||||
|             <td class="address text-left"> | ||||
|               <span class="skeleton-loader" style="max-width: 300px"></span> | ||||
|             </td> | ||||
|             <td class="amount text-right"> | ||||
|               <span class="skeleton-loader" style="max-width: 140px"></span> | ||||
|             </td> | ||||
|             <td class="pegin text-left"> | ||||
|               <span class="skeleton-loader" style="max-width: 300px"></span> | ||||
|             </td> | ||||
|             <td class="timestamp text-left"> | ||||
|               <span class="skeleton-loader" style="max-width: 140px"></span> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-template> | ||||
|       </ng-template> | ||||
|     </table> | ||||
| 
 | ||||
|     <ngb-pagination *ngIf="!widget && federationUtxos$ | async as utxos" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''" | ||||
|       [collectionSize]="utxos.length" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page" | ||||
|       (pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false"> | ||||
|     </ngb-pagination> | ||||
| 
 | ||||
|     <ng-template [ngIf]="!widget"> | ||||
|       <div class="clearfix"></div> | ||||
|       <br> | ||||
|     </ng-template> | ||||
|   </div> | ||||
|    | ||||
| </div> | ||||
| @ -0,0 +1,94 @@ | ||||
| .spinner-border { | ||||
|   height: 25px; | ||||
|   width: 25px; | ||||
|   margin-top: 13px; | ||||
| } | ||||
| 
 | ||||
| tr, td, th { | ||||
|   border: 0px; | ||||
|   padding-top: 0.65rem !important; | ||||
|   padding-bottom: 0.6rem !important; | ||||
|   padding-right: 2rem !important; | ||||
|   .widget { | ||||
|     padding-right: 1rem !important; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .clear-link { | ||||
|   color: white; | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| 
 | ||||
| .progress { | ||||
|   background-color: #2d3348; | ||||
| } | ||||
| 
 | ||||
| .txid { | ||||
|   width: 25%; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   max-width: 160px; | ||||
| } | ||||
| .txid.widget { | ||||
|   width: 40%; | ||||
|    | ||||
| } | ||||
| 
 | ||||
| .address { | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   max-width: 160px; | ||||
|   @media (max-width: 527px) { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .amount { | ||||
|   width: 12%; | ||||
| } | ||||
| .amount.widget { | ||||
|   width: 30%; | ||||
| } | ||||
| 
 | ||||
| .pegin { | ||||
|   width: 25%; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   max-width: 160px; | ||||
|   @media (max-width: 872px) { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .timestamp { | ||||
|   width: 18%; | ||||
|   @media (max-width: 800px) { | ||||
|     display: none; | ||||
|   } | ||||
|   @media (max-width: 1000px) { | ||||
|     .relative-time { | ||||
|       display: none; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| .timestamp.widget { | ||||
|   width: 100%; | ||||
|   @media (min-width: 768px) AND (max-width: 1050px) { | ||||
|     display: none; | ||||
|   } | ||||
|   @media (max-width: 767px) { | ||||
|     display: block; | ||||
|   } | ||||
| 
 | ||||
|   @media (max-width: 500px) { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @ -0,0 +1,109 @@ | ||||
| import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core'; | ||||
| import { Observable, Subject, combineLatest, of, timer } from 'rxjs'; | ||||
| import { delayWhen, filter, map, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../../services/api.service'; | ||||
| import { Env, StateService } from '../../../services/state.service'; | ||||
| import { AuditStatus, CurrentPegs, FederationUtxo } from '../../../interfaces/node-api.interface'; | ||||
| import { WebsocketService } from '../../../services/websocket.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-federation-utxos-list', | ||||
|   templateUrl: './federation-utxos-list.component.html', | ||||
|   styleUrls: ['./federation-utxos-list.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class FederationUtxosListComponent implements OnInit { | ||||
|   @Input() widget: boolean = false; | ||||
|   @Input() federationUtxos$: Observable<FederationUtxo[]>; | ||||
| 
 | ||||
|   env: Env; | ||||
|   isLoading = true; | ||||
|   page = 1; | ||||
|   pageSize = 15; | ||||
|   maxSize = window.innerWidth <= 767.98 ? 3 : 5; | ||||
|   skeletonLines: number[] = []; | ||||
|   auditStatus$: Observable<AuditStatus>; | ||||
|   auditUpdated$: Observable<boolean>; | ||||
|   lastReservesBlockUpdate: number = 0; | ||||
|   currentPeg$: Observable<CurrentPegs>; | ||||
|   lastPegBlockUpdate: number = 0; | ||||
|   lastPegAmount: string = ''; | ||||
|   isLoad: boolean = true; | ||||
| 
 | ||||
|   private destroy$ = new Subject(); | ||||
|    | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     public stateService: StateService, | ||||
|     private websocketService: WebsocketService, | ||||
|   ) { | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.isLoading = !this.widget; | ||||
|     this.env = this.stateService.env; | ||||
|     this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()]; | ||||
| 
 | ||||
|     if (!this.widget) { | ||||
|       this.websocketService.want(['blocks']); | ||||
|       this.auditStatus$ = this.stateService.blocks$.pipe( | ||||
|         takeUntil(this.destroy$), | ||||
|         throttleTime(40000), | ||||
|         delayWhen(_ => this.isLoad ? timer(0) : timer(2000)), | ||||
|         tap(() => this.isLoad = false), | ||||
|         switchMap(() => this.apiService.federationAuditSynced$()), | ||||
|         shareReplay(1) | ||||
|       ); | ||||
| 
 | ||||
|       this.currentPeg$ = this.auditStatus$.pipe( | ||||
|         filter(auditStatus => auditStatus.isAuditSynced === true), | ||||
|         switchMap(_ => | ||||
|           this.apiService.liquidPegs$().pipe( | ||||
|             filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate), | ||||
|             tap((currentPegs) => { | ||||
|               this.lastPegBlockUpdate = currentPegs.lastBlockUpdate; | ||||
|             }) | ||||
|           ) | ||||
|         ), | ||||
|         share() | ||||
|       ); | ||||
| 
 | ||||
|       this.auditUpdated$ = combineLatest([ | ||||
|         this.auditStatus$, | ||||
|         this.currentPeg$ | ||||
|       ]).pipe( | ||||
|         filter(([auditStatus, _]) => auditStatus.isAuditSynced === true), | ||||
|         map(([auditStatus, currentPeg]) => ({ | ||||
|           lastBlockAudit: auditStatus.lastBlockAudit, | ||||
|           currentPegAmount: currentPeg.amount | ||||
|         })), | ||||
|         switchMap(({ lastBlockAudit, currentPegAmount }) => { | ||||
|           const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate; | ||||
|           const amountCheck = currentPegAmount !== this.lastPegAmount; | ||||
|           this.lastReservesBlockUpdate = lastBlockAudit; | ||||
|           this.lastPegAmount = currentPegAmount; | ||||
|           return of(blockAuditCheck || amountCheck); | ||||
|         }), | ||||
|         share() | ||||
|       ); | ||||
| 
 | ||||
|       this.federationUtxos$ = this.auditUpdated$.pipe( | ||||
|         filter(auditUpdated => auditUpdated === true), | ||||
|         throttleTime(40000), | ||||
|         switchMap(_ => this.apiService.federationUtxos$()), | ||||
|         tap(_ => this.isLoading = false), | ||||
|         share() | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     this.destroy$.next(1); | ||||
|     this.destroy$.complete(); | ||||
|   } | ||||
| 
 | ||||
|   pageChange(page: number): void { | ||||
|     this.page = page; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,24 @@ | ||||
| <div class="container-xl"> | ||||
|   <div> | ||||
|     <h1 i18n="liquid.federation-wallet">Liquid Federation Wallet</h1> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="nav-container"> | ||||
|     <ul class="nav nav-pills"> | ||||
|       <li class="nav-item"> | ||||
|         <a class="nav-link" [routerLink]="['/audit/wallet/utxos' | relativeUrl]" routerLinkActive="active">UTXOs</a> | ||||
|          | ||||
|       </li> | ||||
|       <li class="nav-item"> | ||||
|         <a class="nav-link" [routerLink]="['/audit/wallet/addresses' | relativeUrl]" routerLinkActive="active"><ng-container i18n="mining.addresses">Addresses</ng-container></a> | ||||
|       </li> | ||||
|     </ul> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="clearfix"></div> | ||||
| 
 | ||||
|   <router-outlet></router-outlet> | ||||
| 
 | ||||
| </div> | ||||
| 
 | ||||
| <br> | ||||
| @ -0,0 +1,13 @@ | ||||
| ul { | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
| 
 | ||||
| @media (max-width: 767.98px) { | ||||
|   .nav-container { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     margin: auto; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,20 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { SeoService } from '../../../services/seo.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-federation-wallet', | ||||
|   templateUrl: './federation-wallet.component.html', | ||||
|   styleUrls: ['./federation-wallet.component.scss'] | ||||
| }) | ||||
| export class FederationWalletComponent implements OnInit { | ||||
| 
 | ||||
|   constructor( | ||||
|     private seoService: SeoService | ||||
|   ) { | ||||
|     this.seoService.setTitle($localize`:@@993e5bc509c26db81d93018e24a6afe6e50cae52:Liquid Federation Wallet`); | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,137 @@ | ||||
| <div [ngClass]="{'container-xl': !widget, 'widget': widget}"> | ||||
| 
 | ||||
|   <div *ngIf="!widget"> | ||||
|     <h1 i18n="liquid.recent-pegs">Recent Peg-In / Out's</h1> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="clearfix"></div> | ||||
|    | ||||
|   <div style="min-height: 295px"> | ||||
|     <table class="table table-borderless"> | ||||
|       <thead style="vertical-align: middle;"> | ||||
|         <th class="transaction text-left" [ngClass]="{'widget': widget}" i18n="shared.transaction">Transaction</th> | ||||
|         <th class="timestamp text-left" i18n="shared.date" [ngClass]="{'widget': widget}">Date</th> | ||||
|         <th class="amount text-right" [ngClass]="{'widget': widget}" i18n="shared.amount">Amount</th> | ||||
|         <th class="output text-left" *ngIf="!widget" i18n="liquid.fund-redemption-tx">Fund / Redemption Tx</th> | ||||
|         <th class="address text-left" *ngIf="!widget" i18n="liquid.bitcoin-address">BTC Address</th> | ||||
|       </thead> | ||||
|       <tbody *ngIf="recentPegsList$ | async as pegs; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''"> | ||||
|         <ng-container *ngIf="widget; else regularRows"> | ||||
|           <tr *ngFor="let peg of pegs | slice:0:5"> | ||||
|             <td class="transaction text-left widget"> | ||||
|               <ng-container *ngIf="peg.amount > 0"> | ||||
|                 <a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vin=' + peg.txindex"> | ||||
|                   <app-truncate [text]="peg.txid"></app-truncate> | ||||
|                 </a> | ||||
|               </ng-container> | ||||
|               <ng-container *ngIf="peg.amount < 0"> | ||||
|                 <a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vout=' + peg.txindex"> | ||||
|                   <app-truncate [text]="peg.txid"></app-truncate> | ||||
|                 </a> | ||||
|               </ng-container> | ||||
|             </td> | ||||
|             <td class="timestamp text-left widget"> | ||||
|               <app-time kind="since" [time]="peg.blocktime"></app-time> | ||||
|             </td> | ||||
|             <td class="amount text-right widget" [ngClass]="{'credit': peg.amount > 0, 'debit': peg.amount < 0, 'glow-effect': peg.amount < 0 && peg.bitcoinaddress && !peg.bitcointxid}"> | ||||
|               <app-amount [satoshis]="peg.amount" [noFiat]="true" [forceBtc]="true" [addPlus]="true"></app-amount> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-container> | ||||
|         <ng-template #regularRows> | ||||
|           <tr *ngFor="let peg of pegs;"> | ||||
|             <td class="transaction text-left"> | ||||
|               <ng-container *ngIf="peg.amount > 0"> | ||||
|                 <a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vin=' + peg.txindex"> | ||||
|                   <app-truncate [text]="peg.txid" [lastChars]="6"></app-truncate> | ||||
|                 </a> | ||||
|               </ng-container> | ||||
|               <ng-container *ngIf="peg.amount < 0"> | ||||
|                 <a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vout=' + peg.txindex"> | ||||
|                   <app-truncate [text]="peg.txid" [lastChars]="6"></app-truncate> | ||||
|                 </a> | ||||
|               </ng-container> | ||||
|             </td> | ||||
|             <td class="timestamp text-left"> | ||||
|               ‎{{ peg.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }} | ||||
|               <div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="peg.blocktime"></app-time>)</i></div> | ||||
|             </td> | ||||
|             <td class="amount text-right" [ngClass]="{'credit': peg.amount > 0, 'debit': peg.amount < 0, 'glow-effect': peg.amount < 0 && peg.bitcoinaddress && !peg.bitcointxid}"> | ||||
|               <app-amount [satoshis]="peg.amount" [noFiat]="true" [forceBtc]="true" [addPlus]="true"></app-amount> | ||||
|             </td> | ||||
|             <td class="output text-left"> | ||||
|               <ng-container *ngIf="peg.bitcointxid; else redeemInProgress"> | ||||
|                 <a href="{{ env.MEMPOOL_WEBSITE_URL + '/tx/' + peg.bitcointxid + ':' + peg.bitcoinindex }}" target="_blank" style="color:#b86d12"> | ||||
|                   <app-truncate [text]="peg.bitcointxid + ':' + peg.bitcoinindex" [lastChars]="6"></app-truncate> | ||||
|                 </a> | ||||
|               </ng-container> | ||||
|               <ng-template #redeemInProgress> | ||||
|                 <ng-container *ngIf="peg.bitcoinaddress; else noRedeem"> | ||||
|                   <i><span class="text-muted" i18n="liquid.redemption-in-progress">Peg out in progress...</span></i> | ||||
|                 </ng-container> | ||||
|               </ng-template> | ||||
|             </td> | ||||
|             <td class="address text-left"> | ||||
|               <ng-container *ngIf="peg.bitcoinaddress; else noRedeem"> | ||||
|                 <a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + peg.bitcoinaddress }}" target="_blank" style="color:#b86d12"> | ||||
|                   <app-truncate [text]="peg.bitcoinaddress" [lastChars]="6"></app-truncate> | ||||
|                 </a> | ||||
|               </ng-container> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-template> | ||||
|       </tbody> | ||||
|       <ng-template #skeleton> | ||||
|         <tbody *ngIf="widget; else regularRowsSkeleton"> | ||||
|           <tr *ngFor="let item of skeletonLines"> | ||||
|             <td class="transaction text-left widget"> | ||||
|               <span class="skeleton-loader" style="max-width: 400px"></span> | ||||
|             </td> | ||||
|             <td class="timestamp text-left widget"> | ||||
|               <span class="skeleton-loader" style="max-width: 300px"></span> | ||||
|             </td> | ||||
|             <td class="amount text-right widget"> | ||||
|               <span class="skeleton-loader" style="max-width: 300px"></span> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </tbody> | ||||
|         <ng-template #regularRowsSkeleton> | ||||
|           <tr *ngFor="let item of skeletonLines"> | ||||
|             <td class="transaction text-left"> | ||||
|               <span class="skeleton-loader" style="max-width: 300px"></span> | ||||
|             </td> | ||||
|             <td class="timestamp text-left"> | ||||
|               <span class="skeleton-loader" style="max-width: 240px"></span> | ||||
|             </td> | ||||
|             <td class="amount text-right"> | ||||
|               <span class="skeleton-loader" style="max-width: 140px"></span> | ||||
|             </td> | ||||
|             <td class="output text-left"> | ||||
|               <span class="skeleton-loader" style="max-width: 300px"></span> | ||||
|             </td> | ||||
|             <td class="address text-left"> | ||||
|               <span class="skeleton-loader" style="max-width: 240px"></span> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </ng-template> | ||||
|       </ng-template> | ||||
|     </table> | ||||
| 
 | ||||
|     <ngb-pagination *ngIf="!widget && pegsCount$ | async as pegsCount" class="pagination-container float-right mt-2" [class]="isLoading || isPegCountLoading ? 'disabled' : ''" | ||||
|       [collectionSize]="pegsCount" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page" | ||||
|       (pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false"> | ||||
|     </ngb-pagination> | ||||
| 
 | ||||
|     <ng-template [ngIf]="!widget"> | ||||
|       <div class="clearfix"></div> | ||||
|       <br> | ||||
|     </ng-template> | ||||
|   </div> | ||||
|    | ||||
| </div> | ||||
| 
 | ||||
| <br> | ||||
| 
 | ||||
| <ng-template #noRedeem> | ||||
|   <span class="text-muted">-</span> | ||||
| </ng-template> | ||||
| @ -0,0 +1,123 @@ | ||||
| .spinner-border { | ||||
|   height: 25px; | ||||
|   width: 25px; | ||||
|   margin-top: 13px; | ||||
| } | ||||
| 
 | ||||
| tr, td, th { | ||||
|   border: 0px; | ||||
|   padding-top: 0.65rem; | ||||
|   padding-bottom: 0.6rem; | ||||
|   padding-right: 2rem; | ||||
|   .widget &.widget { | ||||
|     padding-right: 1rem; | ||||
|     @media (max-width: 510px) { | ||||
|       padding-right: 0.5rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .clear-link { | ||||
|   color: white; | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
| 
 | ||||
| .progress { | ||||
|   background-color: #2d3348; | ||||
| } | ||||
| 
 | ||||
| .transaction { | ||||
|   width: 20%; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   max-width: 120px; | ||||
| } | ||||
| .transaction.widget { | ||||
|   width: 100%; | ||||
|    | ||||
| } | ||||
| 
 | ||||
| .address { | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   max-width: 160px; | ||||
|   @media (max-width: 527px) { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .amount { | ||||
|   width: 0%; | ||||
| } | ||||
| 
 | ||||
| .output { | ||||
|   width: 20%; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   max-width: 160px; | ||||
|   @media (max-width: 800px) { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .address { | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   max-width: 160px; | ||||
|   @media (max-width: 960px) { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .timestamp { | ||||
|   width: 0%; | ||||
|   @media (max-width: 650px) { | ||||
|     display: none; | ||||
|   } | ||||
|   @media (max-width: 1000px) { | ||||
|     .relative-time { | ||||
|       display: none; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| .timestamp.widget { | ||||
|   @media (min-width: 768px) AND (max-width: 1050px) { | ||||
|     display: none; | ||||
|   } | ||||
|   @media (max-width: 767px) { | ||||
|     display: block; | ||||
|   } | ||||
| 
 | ||||
|   @media (max-width: 510px) { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .credit { | ||||
|   color: #7CB342; | ||||
| } | ||||
| 
 | ||||
| .debit { | ||||
|   color: #D81B60; | ||||
| } | ||||
| 
 | ||||
| .glow-effect { | ||||
|   animation: color-oscillation 1s ease-in-out infinite alternate; | ||||
| } | ||||
| 
 | ||||
| @keyframes color-oscillation { | ||||
|   0% { | ||||
|       color: #777983; | ||||
|   } | ||||
|   100% { | ||||
|       color: #D81B60; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,139 @@ | ||||
| import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core'; | ||||
| import { BehaviorSubject, Observable, Subject, combineLatest, of, timer } from 'rxjs'; | ||||
| import { delayWhen, filter, map, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../../services/api.service'; | ||||
| import { Env, StateService } from '../../../services/state.service'; | ||||
| import { AuditStatus, CurrentPegs, RecentPeg } from '../../../interfaces/node-api.interface'; | ||||
| import { WebsocketService } from '../../../services/websocket.service'; | ||||
| import { SeoService } from '../../../services/seo.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-recent-pegs-list', | ||||
|   templateUrl: './recent-pegs-list.component.html', | ||||
|   styleUrls: ['./recent-pegs-list.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class RecentPegsListComponent implements OnInit { | ||||
|   @Input() widget: boolean = false; | ||||
|   @Input() recentPegsList$: Observable<RecentPeg[]>; | ||||
| 
 | ||||
|   env: Env; | ||||
|   isLoading = true; | ||||
|   isPegCountLoading = true; | ||||
|   page = 1; | ||||
|   pageSize = 15; | ||||
|   maxSize = window.innerWidth <= 767.98 ? 3 : 5; | ||||
|   skeletonLines: number[] = []; | ||||
|   auditStatus$: Observable<AuditStatus>; | ||||
|   auditUpdated$: Observable<boolean>; | ||||
|   lastReservesBlockUpdate: number = 0; | ||||
|   currentPeg$: Observable<CurrentPegs>; | ||||
|   pegsCount$: Observable<number>; | ||||
|   startingIndexSubject: BehaviorSubject<number> = new BehaviorSubject(0); | ||||
|   currentIndex: number = 0; | ||||
|   lastPegBlockUpdate: number = 0; | ||||
|   lastPegAmount: string = ''; | ||||
|   isLoad: boolean = true; | ||||
| 
 | ||||
|   private destroy$ = new Subject(); | ||||
|    | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     public stateService: StateService, | ||||
|     private websocketService: WebsocketService, | ||||
|     private seoService: SeoService | ||||
|   ) { | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.isLoading = !this.widget; | ||||
|     this.env = this.stateService.env; | ||||
|     this.skeletonLines = this.widget === true ? [...Array(5).keys()] : [...Array(15).keys()]; | ||||
| 
 | ||||
|     if (!this.widget) { | ||||
|       this.seoService.setTitle($localize`:@@a8b0889ea1b41888f1e247f2731cc9322198ca04:Recent Peg-In / Out's`); | ||||
|       this.websocketService.want(['blocks']); | ||||
|       this.auditStatus$ = this.stateService.blocks$.pipe( | ||||
|         takeUntil(this.destroy$), | ||||
|         throttleTime(40000), | ||||
|         delayWhen(_ => this.isLoad ? timer(0) : timer(2000)), | ||||
|         tap(() => this.isLoad = false), | ||||
|         switchMap(() => this.apiService.federationAuditSynced$()), | ||||
|         shareReplay(1) | ||||
|       ); | ||||
| 
 | ||||
|       this.currentPeg$ = this.auditStatus$.pipe( | ||||
|         filter(auditStatus => auditStatus.isAuditSynced === true), | ||||
|         switchMap(_ => | ||||
|           this.apiService.liquidPegs$().pipe( | ||||
|             filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate), | ||||
|             tap((currentPegs) => { | ||||
|               this.lastPegBlockUpdate = currentPegs.lastBlockUpdate; | ||||
|             }) | ||||
|           ) | ||||
|         ), | ||||
|         share() | ||||
|       ); | ||||
| 
 | ||||
|       this.auditUpdated$ = combineLatest([ | ||||
|         this.auditStatus$, | ||||
|         this.currentPeg$ | ||||
|       ]).pipe( | ||||
|         filter(([auditStatus, _]) => auditStatus.isAuditSynced === true), | ||||
|         map(([auditStatus, currentPeg]) => ({ | ||||
|           lastBlockAudit: auditStatus.lastBlockAudit, | ||||
|           currentPegAmount: currentPeg.amount | ||||
|         })), | ||||
|         switchMap(({ lastBlockAudit, currentPegAmount }) => { | ||||
|           const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate; | ||||
|           const amountCheck = currentPegAmount !== this.lastPegAmount; | ||||
|           this.lastReservesBlockUpdate = lastBlockAudit; | ||||
|           this.lastPegAmount = currentPegAmount; | ||||
|           return of(blockAuditCheck || amountCheck); | ||||
|         }), | ||||
|         share() | ||||
|       ); | ||||
| 
 | ||||
|       this.pegsCount$ = this.auditUpdated$.pipe( | ||||
|         filter(auditUpdated => auditUpdated === true), | ||||
|         tap(() => this.isPegCountLoading = true), | ||||
|         switchMap(_ => this.apiService.pegsCount$()), | ||||
|         map((data) => data.pegs_count), | ||||
|         tap(() => this.isPegCountLoading = false), | ||||
|         share() | ||||
|       ); | ||||
| 
 | ||||
|       this.recentPegsList$ = combineLatest([ | ||||
|         this.auditStatus$, | ||||
|         this.auditUpdated$, | ||||
|         this.startingIndexSubject | ||||
|       ]).pipe( | ||||
|         filter(([auditStatus, auditUpdated, startingIndex]) => { | ||||
|           const auditStatusCheck = auditStatus.isAuditSynced === true; | ||||
|           const auditUpdatedCheck = auditUpdated === true; | ||||
|           const startingIndexCheck = startingIndex !== this.currentIndex; | ||||
|           return auditStatusCheck && (auditUpdatedCheck || startingIndexCheck); | ||||
|         }), | ||||
|         tap(([_, __, startingIndex]) => { | ||||
|           this.currentIndex = startingIndex; | ||||
|           this.isLoading = true; | ||||
|         }), | ||||
|         switchMap(([_, __, startingIndex]) => this.apiService.recentPegsList$(startingIndex)), | ||||
|         tap(() => this.isLoading = false), | ||||
|         share() | ||||
|       ); | ||||
|    | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     this.destroy$.next(1); | ||||
|     this.destroy$.complete(); | ||||
|   } | ||||
| 
 | ||||
|   pageChange(page: number): void { | ||||
|     this.startingIndexSubject.next((page - 1) * 15); | ||||
|     this.page = page; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,47 @@ | ||||
| <div *ngIf="(pegsVolume$ | async) as pegsVolume; else loadingData"> | ||||
|   <div class="fee-estimation-container"> | ||||
|     <div class="item"> | ||||
|       <a class="title-link" [routerLink]="['/audit/pegs' | relativeUrl]"> | ||||
|         <h5 class="card-title"><ng-container i18n="liquid.recent-pegs">Recent Peg-In / Out's</ng-container> <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5> | ||||
|       </a> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="fee-estimation-container"> | ||||
|     <div class="item"> | ||||
|       <div class="card-text"> | ||||
|         <div class="fee-text credit" i18n-ngbTooltip="liquid.peg-ins-volume-day" ngbTooltip="24h Peg-In Volume" placement="top">+{{ (+pegsVolume[0].volume) / 100000000 | number: '1.2-2' }} <span i18n="shared.addresses">BTC</span></div> | ||||
|         <div class="fiat">{{ (+pegsVolume[0].number) }} <span i18n="liquid.peg-ins">Peg-Ins</span></div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="item"> | ||||
|       <div class="card-text"> | ||||
|         <div class="fee-text debit" i18n-ngbTooltip="liquid.peg-out-volume-day" ngbTooltip="24h Peg-Out Volume" placement="top">{{ (+pegsVolume[1].volume) / 100000000 | number: '1.2-2' }} <span i18n="shared.addresses">BTC</span></div> | ||||
|         <div class="fiat">{{ (+pegsVolume[1].number) }} <span i18n="liquid.peg-outs">Peg-Outs</span></div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| 
 | ||||
| <ng-template #loadingData> | ||||
|   <div class="fee-estimation-container loading-container"> | ||||
|     <div class="item"> | ||||
|       <a class="title-link" [routerLink]="['/audit/pegs' | relativeUrl]"> | ||||
|         <h5 class="card-title"><ng-container i18n="liquid.recent-pegs">Recent Peg-In / Out's</ng-container> <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5> | ||||
|       </a> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="fee-estimation-container"> | ||||
|     <div class="item"> | ||||
|       <div class="card-text"> | ||||
|         <div class="skeleton-loader"></div> | ||||
|         <div class="skeleton-loader"></div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="item"> | ||||
|       <div class="card-text"> | ||||
|         <div class="skeleton-loader"></div> | ||||
|         <div class="skeleton-loader"></div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </ng-template> | ||||
| @ -0,0 +1,80 @@ | ||||
| .fee-estimation-container { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   margin-bottom: 10px; | ||||
|   @media (min-width: 376px) { | ||||
|     flex-direction: row; | ||||
|   }   | ||||
|   .item { | ||||
|     max-width: 300px; | ||||
|     margin: 0; | ||||
|     width: -webkit-fill-available; | ||||
|     @media (min-width: 376px) { | ||||
|       margin: 0 auto 0px; | ||||
|     } | ||||
| 
 | ||||
|     .card-title { | ||||
|       margin: 0; | ||||
|       color: #4a68b9; | ||||
|       font-size: 10px; | ||||
|       font-size: 1rem; | ||||
|       white-space: nowrap; | ||||
|     } | ||||
| 
 | ||||
|     .card-text { | ||||
|       font-size: 22px; | ||||
|       span { | ||||
|         font-size: 11px; | ||||
|         position: relative; | ||||
|         top: -2px; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .card-text span { | ||||
|       color: #ffffff66; | ||||
|       font-size: 12px; | ||||
|       top: 0px; | ||||
|     } | ||||
|     .fee-text{ | ||||
|       border-bottom: 1px solid #ffffff1c; | ||||
|       width: fit-content; | ||||
|       margin: auto; | ||||
|       line-height: 1.45; | ||||
|       padding: 0px 2px; | ||||
|     } | ||||
|     .fiat { | ||||
|       display: block; | ||||
|       font-size: 14px !important; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .card-text { | ||||
|   .skeleton-loader { | ||||
|     width: 100%; | ||||
|     display: block; | ||||
|     &:first-child { | ||||
|       max-width: 90px; | ||||
|       margin: 15px auto 3px; | ||||
|     } | ||||
|     &:last-child { | ||||
|       margin: 10px auto 3px; | ||||
|       max-width: 55px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .title-link, .title-link:hover, .title-link:focus, .title-link:active { | ||||
|   display: block; | ||||
|   margin-bottom: 4px; | ||||
|   text-decoration: none; | ||||
|   color: inherit; | ||||
| } | ||||
| 
 | ||||
| .credit { | ||||
|   color: #7CB342; | ||||
| } | ||||
| 
 | ||||
| .debit { | ||||
|   color: #D81B60; | ||||
| } | ||||
| @ -0,0 +1,19 @@ | ||||
| import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { PegsVolume } from '../../../interfaces/node-api.interface'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-recent-pegs-stats', | ||||
|   templateUrl: './recent-pegs-stats.component.html', | ||||
|   styleUrls: ['./recent-pegs-stats.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class RecentPegsStatsComponent implements OnInit { | ||||
|   @Input() pegsVolume$: Observable<PegsVolume[]>; | ||||
| 
 | ||||
|   constructor() { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,42 @@ | ||||
| <div *ngIf="(unbackedMonths$ | async) as unbackedMonths; else loadingData"> | ||||
|   <ng-container *ngIf="unbackedMonths.historyComplete; else loadingData"> | ||||
|     <div class="fee-estimation-container"> | ||||
|       <div class="item">  | ||||
|         <h5 class="card-title" i18n="liquid.unpeg">Unpeg</h5>  | ||||
|         <div class="card-text"> | ||||
|           <div class="fee-text" [ngClass]="{'danger' : unbackedMonths.total > 0, 'correct': unbackedMonths.total === 0}"> | ||||
|             {{ unbackedMonths.total }} <span i18n="liquid.unpeg-event">Unpeg Event</span> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="item">  | ||||
|         <h5 class="card-title" i18n="liquid.avg-peg-ratio">Avg Peg Ratio</h5>  | ||||
|         <div class="card-text"> | ||||
|           <div class="fee-text" [ngClass]="{'danger' : unbackedMonths.avg < 1, 'correct': unbackedMonths.avg >= 1}"> | ||||
|             {{ (unbackedMonths.avg * 100).toFixed(3) }} % | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </ng-container> | ||||
| </div> | ||||
| 
 | ||||
| <ng-template #loadingData> | ||||
|   <div class="fee-estimation-container loading-container"> | ||||
|     <div class="item"> | ||||
|       <h5 class="card-title" i18n="liquid.unpeg">Unpeg</h5> | ||||
|       <div class="card-text"> | ||||
|         <div class="skeleton-loader"></div> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="item">  | ||||
|       <h5 class="card-title" i18n="liquid.avg-peg-ratio">Avg Peg Ratio</h5>  | ||||
|       <div class="card-text"> | ||||
|         <div class="skeleton-loader"></div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </ng-template> | ||||
| 
 | ||||
| @ -0,0 +1,63 @@ | ||||
| .fee-estimation-container { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   @media (min-width: 376px) { | ||||
|     flex-direction: row; | ||||
|   }   | ||||
|   .item { | ||||
|     max-width: 300px; | ||||
|     margin: 0; | ||||
|     width: -webkit-fill-available; | ||||
|     @media (min-width: 376px) { | ||||
|       margin: 0 auto 0px; | ||||
|     } | ||||
| 
 | ||||
|     .card-title { | ||||
|       margin-bottom: 4px; | ||||
|       color: #4a68b9; | ||||
|       font-size: 10px; | ||||
|       font-size: 1rem; | ||||
|       white-space: nowrap; | ||||
|     } | ||||
| 
 | ||||
|     .card-text { | ||||
|       font-size: 22px; | ||||
|       span { | ||||
|         font-size: 11px; | ||||
|         position: relative; | ||||
|         top: -2px; | ||||
|       } | ||||
|       .danger { | ||||
|         color: #D81B60; | ||||
|       } | ||||
|       .correct { | ||||
|         color: #7CB342; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .card-text span { | ||||
|       color: #ffffff66; | ||||
|       font-size: 12px; | ||||
|       top: 0px; | ||||
|     } | ||||
|     .fee-text{ | ||||
|       width: fit-content; | ||||
|       margin: auto; | ||||
|       line-height: 1.45; | ||||
|       padding: 0px 2px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .loading-container{ | ||||
|   min-height: 76px; | ||||
| } | ||||
| 
 | ||||
| .card-text { | ||||
|   .skeleton-loader { | ||||
|     width: 100%; | ||||
|     display: block; | ||||
|     max-width: 90px; | ||||
|     margin: 15px auto 3px; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,51 @@ | ||||
| import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; | ||||
| import { Observable, map } from 'rxjs'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-reserves-ratio-stats', | ||||
|   templateUrl: './reserves-ratio-stats.component.html', | ||||
|   styleUrls: ['./reserves-ratio-stats.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class ReservesRatioStatsComponent implements OnInit { | ||||
|   @Input() fullHistory$: Observable<any>; | ||||
|   unbackedMonths$: Observable<any> | ||||
| 
 | ||||
|   constructor() { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     if (!this.fullHistory$) { | ||||
|       return; | ||||
|     } | ||||
|     this.unbackedMonths$ = this.fullHistory$ | ||||
|       .pipe( | ||||
|         map((fullHistory) => { | ||||
|           if (fullHistory.liquidPegs.series.length !== fullHistory.liquidReserves.series.length) { | ||||
|             return { | ||||
|               historyComplete: false,  | ||||
|               total: null | ||||
|             }; | ||||
|           } | ||||
|           // Only check the last 3 years
 | ||||
|           let ratioSeries = fullHistory.liquidReserves.series.map((value: number, index: number) => value / fullHistory.liquidPegs.series[index]); | ||||
|           ratioSeries = ratioSeries.slice(Math.max(ratioSeries.length - 36, 0));           | ||||
|           let total = 0; | ||||
|           let avg = 0; | ||||
|           for (let i = 0; i < ratioSeries.length; i++) { | ||||
|             avg += ratioSeries[i]; | ||||
|             if (ratioSeries[i] < 1) { | ||||
|               total++; | ||||
|             } | ||||
|           } | ||||
|           avg = avg / ratioSeries.length; | ||||
|           return { | ||||
|             historyComplete: true,  | ||||
|             total: total, | ||||
|             avg: avg, | ||||
|           }; | ||||
|         }) | ||||
|       ); | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,4 @@ | ||||
| <div class="echarts" echarts [initOpts]="ratioChartInitOptions" [options]="ratioChartOptions" (chartRendered)="rendered()"></div> | ||||
| <div class="text-center loadingGraphs" *ngIf="isLoading"> | ||||
|   <div class="spinner-border text-light"></div> | ||||
| </div> | ||||
| @ -0,0 +1,6 @@ | ||||
| .loadingGraphs { | ||||
|   position: absolute; | ||||
|   top: 50%; | ||||
|   left: calc(50% - 16px); | ||||
|   z-index: 100; | ||||
| } | ||||
| @ -0,0 +1,175 @@ | ||||
| import { Component, ChangeDetectionStrategy, Input, OnChanges, OnInit, HostListener } from '@angular/core'; | ||||
| import { EChartsOption } from '../../../graphs/echarts'; | ||||
| import { CurrentPegs } from '../../../interfaces/node-api.interface'; | ||||
| 
 | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-reserves-ratio', | ||||
|   templateUrl: './reserves-ratio.component.html', | ||||
|   styleUrls: ['./reserves-ratio.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class ReservesRatioComponent implements OnInit, OnChanges { | ||||
|   @Input() currentPeg: CurrentPegs; | ||||
|   @Input() currentReserves: CurrentPegs; | ||||
|   ratioChartOptions: EChartsOption; | ||||
| 
 | ||||
|   height: number | string = '200'; | ||||
|   right: number | string = '10'; | ||||
|   top: number | string = '20'; | ||||
|   left: number | string = '50'; | ||||
|   template: ('widget' | 'advanced') = 'widget'; | ||||
|   isLoading = true; | ||||
| 
 | ||||
|   ratioChartInitOptions = { | ||||
|     renderer: 'svg' | ||||
|   }; | ||||
| 
 | ||||
|   constructor() { } | ||||
| 
 | ||||
|   ngOnInit() { | ||||
|     this.isLoading = true; | ||||
|   } | ||||
| 
 | ||||
|   ngOnChanges() { | ||||
|     this.updateChartOptions(); | ||||
|   } | ||||
| 
 | ||||
|   updateChartOptions() { | ||||
|     if (!this.currentPeg || !this.currentReserves || this.currentPeg.amount === '0') { | ||||
|       return; | ||||
|     } | ||||
|     this.ratioChartOptions = this.createChartOptions(this.currentPeg, this.currentReserves); | ||||
|   } | ||||
| 
 | ||||
|   rendered() { | ||||
|     if (!this.currentPeg || !this.currentReserves) { | ||||
|       return; | ||||
|     } | ||||
|     this.isLoading = false; | ||||
|   } | ||||
| 
 | ||||
|   createChartOptions(currentPeg: CurrentPegs, currentReserves: CurrentPegs): EChartsOption { | ||||
|     const value = parseFloat(currentReserves.amount) / parseFloat(currentPeg.amount); | ||||
|     const hideMaxAxisLabels = value >= 1.001; | ||||
|     const hideMinAxisLabels = value <= 0.999; | ||||
| 
 | ||||
|     let axisFontSize = 14; | ||||
|     let pointerLength = '50%'; | ||||
|     let pointerWidth = 16; | ||||
|     let offsetCenter = ['0%', '-22%']; | ||||
|     if (window.innerWidth >= 992) { | ||||
|       axisFontSize = 14; | ||||
|       pointerLength = '50%'; | ||||
|       pointerWidth = 16; | ||||
|       offsetCenter = value >= 1.0007 || value <= 0.9993 ? ['0%', '-30%'] : ['0%', '-22%']; | ||||
|     } else if (window.innerWidth >= 768) { | ||||
|       axisFontSize = 10; | ||||
|       pointerLength = '35%'; | ||||
|       pointerWidth = 12; | ||||
|       offsetCenter = value >= 1.0007 || value <= 0.9993 ? ['0%', '-37%'] : ['0%', '-27%']; | ||||
|     } else if (window.innerWidth >= 450) { | ||||
|       axisFontSize = 14; | ||||
|       pointerLength = '45%'; | ||||
|       pointerWidth = 14; | ||||
|       offsetCenter = value >= 1.0007 || value <= 0.9993 ? ['0%', '-32%'] : ['0%', '-22%']; | ||||
|     } else { | ||||
|       axisFontSize = 10; | ||||
|       pointerLength = '35%'; | ||||
|       pointerWidth = 12; | ||||
|       offsetCenter = value >= 1.0007 || value <= 0.9993 ? ['0%', '-37%'] : ['0%', '-27%']; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       series: [ | ||||
|         { | ||||
|           type: 'gauge', | ||||
|           startAngle: 180, | ||||
|           endAngle: 0, | ||||
|           center: ['50%', '75%'], | ||||
|           radius: '100%', | ||||
|           min: 0.999, | ||||
|           max: 1.001, | ||||
|           splitNumber: 2, | ||||
|           axisLine: { | ||||
|             lineStyle: { | ||||
|               width: 6, | ||||
|               color: [ | ||||
|                 [0.49, '#D81B60'], | ||||
|                 [1, '#7CB342'] | ||||
|               ] | ||||
|             } | ||||
|           }, | ||||
|           axisLabel: { | ||||
|             color: 'inherit',         | ||||
|             fontFamily: 'inherit',   | ||||
|             fontSize: axisFontSize,   | ||||
|             formatter: function (value) { | ||||
|               if (value === 0.999) { | ||||
|                 return hideMinAxisLabels ? '' : '99.9%'; | ||||
|               } else if (value === 1.001) { | ||||
|                 return hideMaxAxisLabels ? '' : '100.1%'; | ||||
|               } else { | ||||
|                 return '100%'; | ||||
|               } | ||||
|             }, | ||||
|           }, | ||||
|           pointer: { | ||||
|             icon: 'path://M2090.36389,615.30999 L2090.36389,615.30999 C2091.48372,615.30999 2092.40383,616.194028 2092.44859,617.312956 L2096.90698,728.755929 C2097.05155,732.369577 2094.2393,735.416212 2090.62566,735.56078 C2090.53845,735.564269 2090.45117,735.566014 2090.36389,735.566014 L2090.36389,735.566014 C2086.74736,735.566014 2083.81557,732.63423 2083.81557,729.017692 C2083.81557,728.930412 2083.81732,728.84314 2083.82081,728.755929 L2088.2792,617.312956 C2088.32396,616.194028 2089.24407,615.30999 2090.36389,615.30999 Z', | ||||
|             length: pointerLength, | ||||
|             width: pointerWidth, | ||||
|             offsetCenter: offsetCenter, | ||||
|             itemStyle: { | ||||
|               color: 'auto' | ||||
|             } | ||||
|           }, | ||||
|           axisTick: { | ||||
|             length: 12, | ||||
|             lineStyle: { | ||||
|               color: 'auto', | ||||
|               width: 2 | ||||
|             } | ||||
|           }, | ||||
|           splitLine: { | ||||
|             length: 20, | ||||
|             lineStyle: { | ||||
|               color: 'auto', | ||||
|               width: 5 | ||||
|             } | ||||
|           }, | ||||
|           title: { | ||||
|             show: true, | ||||
|             offsetCenter: [0, '-127%'], | ||||
|             fontSize: 18, | ||||
|             color: '#4a68b9', | ||||
|             fontFamily: 'inherit', | ||||
|             fontWeight: 500, | ||||
|           }, | ||||
|           detail: { | ||||
|             fontSize: 25, | ||||
|             offsetCenter: [0, '-0%'], | ||||
|             valueAnimation: true, | ||||
|             fontFamily: 'inherit', | ||||
|             fontWeight: 500, | ||||
|             formatter: function (value) { | ||||
|               return (value * 100).toFixed(3) + '%'; | ||||
|             }, | ||||
|             color: 'inherit' | ||||
|           }, | ||||
|           data: [ | ||||
|             { | ||||
|               value: value, | ||||
|               name: 'Assets vs Liabilities' | ||||
|             } | ||||
|           ] | ||||
|         } | ||||
|       ] | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('window:resize', ['$event']) | ||||
|   onResize(): void { | ||||
|     this.updateChartOptions(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @ -0,0 +1,29 @@ | ||||
| <div class="fee-estimation-container"> | ||||
|   <div class="item"> | ||||
|     <h5 class="card-title" i18n="dashboard.lbtc-pegs-in-circulation">L-BTC in circulation</h5> | ||||
|     <div *ngIf="(currentPeg$ | async) as currentPeg; else loadingData" class="card-text"> | ||||
|       <div class="fee-text">{{ (+currentPeg.amount) / 100000000 | number: '1.2-2' }} <span>L-BTC</span></div> | ||||
|       <span class="fiat"> | ||||
|         <span><ng-container i18n="shared.as-of-block">As of block</ng-container> <a [routerLink]="['/block', currentPeg.hash]">{{ currentPeg.lastBlockUpdate }}</a></span> | ||||
|       </span> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="item"> | ||||
|     <h5 class="card-title" i18n="dashboard.btc-holdings">BTC Holdings</h5> | ||||
|     <div *ngIf="(currentReserves$ | async) as currentReserves; else loadingData" class="card-text"> | ||||
|       <div class="fee-text">{{ (+currentReserves.amount) / 100000000 | number: '1.2-2' }} <span style="color: #b86d12;">BTC</span></div> | ||||
|       <span class="fiat"> | ||||
|         <span><ng-container i18n="shared.as-of-block">As of block</ng-container> <a href="{{ env.MEMPOOL_WEBSITE_URL + '/block/' + currentReserves.hash }}" target="_blank" style="color:#b86d12">{{ currentReserves.lastBlockUpdate }}</a></span> | ||||
|       </span> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| 
 | ||||
| 
 | ||||
| <ng-template #loadingData> | ||||
|   <div class="card-text"> | ||||
|     <div class="skeleton-loader"></div> | ||||
|     <div class="skeleton-loader"></div> | ||||
|   </div> | ||||
| </ng-template> | ||||
| 
 | ||||
| @ -0,0 +1,74 @@ | ||||
| .fee-estimation-container { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   @media (min-width: 376px) { | ||||
|     flex-direction: row; | ||||
|   }   | ||||
|   .item { | ||||
|     max-width: 150px; | ||||
|     margin: 0; | ||||
|     width: -webkit-fill-available; | ||||
|     @media (min-width: 376px) { | ||||
|       margin: 0 auto 0px; | ||||
|     } | ||||
| 
 | ||||
|     .card-title { | ||||
|       color: #4a68b9; | ||||
|       font-size: 10px; | ||||
|       margin-bottom: 4px;   | ||||
|       font-size: 1rem; | ||||
|       overflow: hidden; | ||||
|       text-overflow: ellipsis; | ||||
|       white-space: nowrap; | ||||
|     } | ||||
| 
 | ||||
|     .card-text { | ||||
|       font-size: 22px; | ||||
|       span { | ||||
|         font-size: 11px; | ||||
|         position: relative; | ||||
|         top: -2px; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     &:last-child { | ||||
|       margin-bottom: 0; | ||||
|     } | ||||
|     .card-text span { | ||||
|       color: #ffffff66; | ||||
|       font-size: 12px; | ||||
|       top: 0px; | ||||
|     } | ||||
|     .fee-text{ | ||||
|       border-bottom: 1px solid #ffffff1c; | ||||
|       color: #ffffff; | ||||
|       width: fit-content; | ||||
|       margin: auto; | ||||
|       line-height: 1.45; | ||||
|       padding: 0px 2px; | ||||
|     } | ||||
|     .fiat { | ||||
|       display: block; | ||||
|       font-size: 14px !important; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .loading-container{ | ||||
|   min-height: 76px; | ||||
| } | ||||
| 
 | ||||
| .card-text { | ||||
|   .skeleton-loader { | ||||
|     width: 100%; | ||||
|     display: block; | ||||
|     &:first-child { | ||||
|       max-width: 90px; | ||||
|       margin: 15px auto 3px; | ||||
|     } | ||||
|     &:last-child { | ||||
|       margin: 10px auto 3px; | ||||
|       max-width: 55px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,24 @@ | ||||
| import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { Env, StateService } from '../../../services/state.service'; | ||||
| import { CurrentPegs } from '../../../interfaces/node-api.interface'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-reserves-supply-stats', | ||||
|   templateUrl: './reserves-supply-stats.component.html', | ||||
|   styleUrls: ['./reserves-supply-stats.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class ReservesSupplyStatsComponent implements OnInit { | ||||
|   @Input() currentReserves$: Observable<CurrentPegs>; | ||||
|   @Input() currentPeg$: Observable<CurrentPegs>; | ||||
| 
 | ||||
|   env: Env; | ||||
| 
 | ||||
|   constructor(private stateService: StateService) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.env = this.stateService.env; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -1,11 +1,13 @@ | ||||
| <app-block-overview-graph | ||||
|   #blockGraph | ||||
|   [isLoading]="isLoading$ | async" | ||||
|   [resolution]="86" | ||||
|   [resolution]="resolution" | ||||
|   [blockLimit]="stateService.blockVSize" | ||||
|   [orientation]="timeLtr ? 'right' : 'left'" | ||||
|   [flip]="true" | ||||
|   [showFilters]="showFilters" | ||||
|   [filterFlags]="filterFlags" | ||||
|   [filterMode]="filterMode" | ||||
|   [overrideColors]="overrideColors" | ||||
|   (txClickEvent)="onTxClick($event)" | ||||
| ></app-block-overview-graph> | ||||
|  | ||||
| @ -10,6 +10,7 @@ import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pi | ||||
| import { Router } from '@angular/router'; | ||||
| import { Color } from '../block-overview-graph/sprite-types'; | ||||
| import TxView from '../block-overview-graph/tx-view'; | ||||
| import { FilterMode } from '../../shared/filters.utils'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-mempool-block-overview', | ||||
| @ -18,8 +19,11 @@ import TxView from '../block-overview-graph/tx-view'; | ||||
| }) | ||||
| export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { | ||||
|   @Input() index: number; | ||||
|   @Input() resolution = 86; | ||||
|   @Input() showFilters: boolean = false; | ||||
|   @Input() overrideColors: ((tx: TxView) => Color) | null = null; | ||||
|   @Input() filterFlags: bigint | undefined = undefined; | ||||
|   @Input() filterMode: FilterMode = 'and'; | ||||
|   @Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>(); | ||||
| 
 | ||||
|   @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; | ||||
| @ -96,7 +100,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang | ||||
|           const inOldBlock = {}; | ||||
|           const inNewBlock = {}; | ||||
|           const added: TransactionStripped[] = []; | ||||
|           const changed: { txid: string, rate: number | undefined, acc: boolean | undefined }[] = []; | ||||
|           const changed: { txid: string, rate: number | undefined, flags: number, acc: boolean | undefined }[] = []; | ||||
|           const removed: string[] = []; | ||||
|           for (const tx of transactionsStripped) { | ||||
|             inNewBlock[tx.txid] = true; | ||||
| @ -114,6 +118,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang | ||||
|               changed.push({ | ||||
|                 txid: tx.txid, | ||||
|                 rate: tx.rate, | ||||
|                 flags: tx.flags, | ||||
|                 acc: tx.acc | ||||
|               }); | ||||
|             } | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user