Merge branch 'master' into nymkappa/tx-overflow
This commit is contained in:
		
						commit
						a133fa8d41
					
				
							
								
								
									
										16
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										16
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,6 +1,7 @@ | |||||||
| version: 2 | version: 2 | ||||||
| updates: | updates: | ||||||
|   - package-ecosystem: npm |   - package-ecosystem: npm | ||||||
|  |     versioning-strategy: increase | ||||||
|     directory: "/backend" |     directory: "/backend" | ||||||
|     schedule: |     schedule: | ||||||
|       interval: daily |       interval: daily | ||||||
| @ -14,6 +15,21 @@ updates: | |||||||
| 
 | 
 | ||||||
|   - package-ecosystem: npm |   - package-ecosystem: npm | ||||||
|     directory: "/frontend" |     directory: "/frontend" | ||||||
|  |     versioning-strategy: increase | ||||||
|  |     groups: | ||||||
|  |       frontend-angular-dependencies: | ||||||
|  |         patterns: | ||||||
|  |           - "@angular*" | ||||||
|  |           - "@ng-*" | ||||||
|  |           - "ngx-*" | ||||||
|  |       frontend-jest-dependencies: | ||||||
|  |         patterns: | ||||||
|  |           - "@types/jest" | ||||||
|  |           - "jest" | ||||||
|  |       frontend-eslint-dependencies: | ||||||
|  |         patterns: | ||||||
|  |           - "@typescript-eslint*" | ||||||
|  |           - "eslint" | ||||||
|     schedule: |     schedule: | ||||||
|       interval: daily |       interval: daily | ||||||
|     open-pull-requests-limit: 10 |     open-pull-requests-limit: 10 | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @ -27,8 +27,17 @@ jobs: | |||||||
|           node-version: ${{ matrix.node }} |           node-version: ${{ matrix.node }} | ||||||
|           registry-url: "https://registry.npmjs.org" |           registry-url: "https://registry.npmjs.org" | ||||||
| 
 | 
 | ||||||
|       - name: Install 1.70.x Rust toolchain |       - name: Read rust-toolchain file from repository | ||||||
|         uses: dtolnay/rust-toolchain@1.70 |         id: gettoolchain | ||||||
|  |         run: echo "::set-output name=toolchain::$(cat rust-toolchain)" | ||||||
|  |         working-directory: ${{ matrix.node }}/${{ matrix.flavor }} | ||||||
|  | 
 | ||||||
|  |       - name: Install ${{ steps.gettoolchain.outputs.toolchain }} Rust toolchain | ||||||
|  |         # Latest version available on this commit is 1.71.1 | ||||||
|  |         # Commit date is Aug 3, 2023 | ||||||
|  |         uses: dtolnay/rust-toolchain@f361669954a8ecfc00a3443f35f9ac8e610ffc06 | ||||||
|  |         with: | ||||||
|  |           toolchain: ${{ steps.gettoolchain.outputs.toolchain }} | ||||||
| 
 | 
 | ||||||
|       - name: Install |       - name: Install | ||||||
|         if: ${{ matrix.flavor == 'dev'}} |         if: ${{ matrix.flavor == 'dev'}} | ||||||
| @ -47,7 +56,7 @@ jobs: | |||||||
| 
 | 
 | ||||||
|       - name: Unit Tests |       - name: Unit Tests | ||||||
|         if: ${{ matrix.flavor == 'dev'}} |         if: ${{ matrix.flavor == 'dev'}} | ||||||
|         run: npm run test |         run: npm run test:ci | ||||||
|         working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/backend |         working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/backend | ||||||
| 
 | 
 | ||||||
|       - name: Build |       - name: Build | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/cypress.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/cypress.yml
									
									
									
									
										vendored
									
									
								
							| @ -38,7 +38,7 @@ jobs: | |||||||
|       - name: Setup node |       - name: Setup node | ||||||
|         uses: actions/setup-node@v3 |         uses: actions/setup-node@v3 | ||||||
|         with: |         with: | ||||||
|           node-version: 16.15.0 |           node-version: 18 | ||||||
|           cache: "npm" |           cache: "npm" | ||||||
|           cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json |           cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										3
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -45,3 +45,6 @@ testem.log | |||||||
| #System Files | #System Files | ||||||
| .DS_Store | .DS_Store | ||||||
| Thumbs.db | Thumbs.db | ||||||
|  | 
 | ||||||
|  | # package folder (npm run package output) | ||||||
|  | /package | ||||||
|  | |||||||
| @ -85,7 +85,7 @@ Install dependencies with `npm` and build the backend: | |||||||
| 
 | 
 | ||||||
| ``` | ``` | ||||||
| cd backend | cd backend | ||||||
| npm install | npm install --no-install-links # npm@9.4.2 and later can omit the --no-install-links | ||||||
| npm run build | npm run build | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -50,7 +50,8 @@ | |||||||
|   "ESPLORA": { |   "ESPLORA": { | ||||||
|     "REST_API_URL": "http://127.0.0.1:3000", |     "REST_API_URL": "http://127.0.0.1:3000", | ||||||
|     "UNIX_SOCKET_PATH": "/tmp/esplora-bitcoin-mainnet", |     "UNIX_SOCKET_PATH": "/tmp/esplora-bitcoin-mainnet", | ||||||
|     "RETRY_UNIX_SOCKET_AFTER": 30000 |     "RETRY_UNIX_SOCKET_AFTER": 30000, | ||||||
|  |     "FALLBACK": [] | ||||||
|   }, |   }, | ||||||
|   "SECOND_CORE_RPC": { |   "SECOND_CORE_RPC": { | ||||||
|     "HOST": "127.0.0.1", |     "HOST": "127.0.0.1", | ||||||
|  | |||||||
| @ -1,14 +1,17 @@ | |||||||
| #/bin/sh | #/bin/sh | ||||||
| set -e | set -e | ||||||
| 
 | 
 | ||||||
|  | # Remove previous dist folder | ||||||
|  | rm -rf dist | ||||||
|  | # Build new dist folder | ||||||
| npm run build | npm run build | ||||||
| # Remove previous package folder | # Remove previous package folder | ||||||
| rm -rf package | rm -rf package | ||||||
| # Move JS and deps | # Move JS and deps | ||||||
| mv dist package | mv dist package | ||||||
| mv node_modules package | cp -R node_modules package | ||||||
| # Remove symlink for rust-gbt and insert real folder | # Remove symlink for rust-gbt and insert real folder | ||||||
| rm package/node_modules/rust-gbt | rm package/node_modules/rust-gbt | ||||||
| mv rust-gbt package/node_modules | cp -R rust-gbt package/node_modules | ||||||
| # Clean up deps | # Clean up deps | ||||||
| npm run package-rm-build-deps | npm run package-rm-build-deps | ||||||
|  | |||||||
| @ -22,7 +22,7 @@ | |||||||
|   "main": "index.ts", |   "main": "index.ts", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "tsc": "./node_modules/typescript/bin/tsc -p tsconfig.build.json", |     "tsc": "./node_modules/typescript/bin/tsc -p tsconfig.build.json", | ||||||
|     "build": "npm run tsc && npm run create-resources", |     "build": "npm run rust-build && npm run tsc && npm run create-resources", | ||||||
|     "create-resources": "cp ./src/tasks/price-feeds/mtgox-weekly.json ./dist/tasks && node dist/api/fetch-version.js", |     "create-resources": "cp ./src/tasks/price-feeds/mtgox-weekly.json ./dist/tasks && node dist/api/fetch-version.js", | ||||||
|     "package": "./npm_package.sh", |     "package": "./npm_package.sh", | ||||||
|     "package-rm-build-deps": "./npm_package_rm_build_deps.sh", |     "package-rm-build-deps": "./npm_package_rm_build_deps.sh", | ||||||
| @ -31,9 +31,11 @@ | |||||||
|     "reindex-updated-pools": "npm run start-production --update-pools", |     "reindex-updated-pools": "npm run start-production --update-pools", | ||||||
|     "reindex-all-blocks": "npm run start-production --update-pools --reindex-blocks", |     "reindex-all-blocks": "npm run start-production --update-pools --reindex-blocks", | ||||||
|     "test": "./node_modules/.bin/jest --coverage", |     "test": "./node_modules/.bin/jest --coverage", | ||||||
|  |     "test:ci": "CI=true ./node_modules/.bin/jest --coverage", | ||||||
|     "lint": "./node_modules/.bin/eslint . --ext .ts", |     "lint": "./node_modules/.bin/eslint . --ext .ts", | ||||||
|     "lint:fix": "./node_modules/.bin/eslint . --ext .ts --fix", |     "lint:fix": "./node_modules/.bin/eslint . --ext .ts --fix", | ||||||
|     "prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\"" |     "prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\"", | ||||||
|  |     "rust-build": "cd rust-gbt && npm run build-release" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@babel/core": "^7.21.3", |     "@babel/core": "^7.21.3", | ||||||
|  | |||||||
| @ -335,13 +335,15 @@ fn set_relatives(txid: u32, audit_pool: &mut AuditPool) { | |||||||
|     let mut total_sigops: u32 = 0; |     let mut total_sigops: u32 = 0; | ||||||
| 
 | 
 | ||||||
|     for ancestor_id in &ancestors { |     for ancestor_id in &ancestors { | ||||||
|         let Some(ancestor) = audit_pool |         if let Some(ancestor) = audit_pool | ||||||
|             .get(*ancestor_id as usize) |             .get(*ancestor_id as usize) | ||||||
|             .expect("audit_pool contains all ancestors") else { todo!() }; |             .expect("audit_pool contains all ancestors") | ||||||
|         total_fee += ancestor.fee; |         { | ||||||
|         total_sigop_adjusted_weight += ancestor.sigop_adjusted_weight; |             total_fee += ancestor.fee; | ||||||
|         total_sigop_adjusted_vsize += ancestor.sigop_adjusted_vsize; |             total_sigop_adjusted_weight += ancestor.sigop_adjusted_weight; | ||||||
|         total_sigops += ancestor.sigops; |             total_sigop_adjusted_vsize += ancestor.sigop_adjusted_vsize; | ||||||
|  |             total_sigops += ancestor.sigops; | ||||||
|  |         } else { todo!() }; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if let Some(Some(tx)) = audit_pool.get_mut(txid as usize) { |     if let Some(Some(tx)) = audit_pool.get_mut(txid as usize) { | ||||||
|  | |||||||
| @ -51,7 +51,8 @@ | |||||||
|   "ESPLORA": { |   "ESPLORA": { | ||||||
|     "REST_API_URL": "__ESPLORA_REST_API_URL__", |     "REST_API_URL": "__ESPLORA_REST_API_URL__", | ||||||
|     "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__", |     "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__", | ||||||
|     "RETRY_UNIX_SOCKET_AFTER": 888 |     "RETRY_UNIX_SOCKET_AFTER": 888, | ||||||
|  |     "FALLBACK": [] | ||||||
|   }, |   }, | ||||||
|   "SECOND_CORE_RPC": { |   "SECOND_CORE_RPC": { | ||||||
|     "HOST": "__SECOND_CORE_RPC_HOST__", |     "HOST": "__SECOND_CORE_RPC_HOST__", | ||||||
|  | |||||||
							
								
								
									
										24
									
								
								backend/src/__tests__/api/common.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								backend/src/__tests__/api/common.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | import { Common } from '../../api/common'; | ||||||
|  | import { MempoolTransactionExtended } from '../../mempool.interfaces'; | ||||||
|  | 
 | ||||||
|  | const randomTransactions = require('./test-data/transactions-random.json'); | ||||||
|  | const replacedTransactions = require('./test-data/transactions-replaced.json'); | ||||||
|  | const rbfTransactions = require('./test-data/transactions-rbfs.json'); | ||||||
|  | 
 | ||||||
|  | describe('Mempool Utils', () => { | ||||||
|  |   test('should detect RBF transactions with fast method', () => { | ||||||
|  |     const newTransactions = rbfTransactions.concat(randomTransactions); | ||||||
|  |     const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions); | ||||||
|  |     expect(Object.values(result).length).toEqual(2); | ||||||
|  |     expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6'); | ||||||
|  |     expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test.only('should detect RBF transactions with scalable method', () => { | ||||||
|  |     const newTransactions = rbfTransactions.concat(randomTransactions); | ||||||
|  |     const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions, true); | ||||||
|  |     expect(Object.values(result).length).toEqual(2); | ||||||
|  |     expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6'); | ||||||
|  |     expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875'); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										277
									
								
								backend/src/__tests__/api/test-data/transactions-random.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								backend/src/__tests__/api/test-data/transactions-random.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,277 @@ | |||||||
|  | [ | ||||||
|  |     { | ||||||
|  |         "txid": "13f007241d78e8b0b4e57d2ae3fd37bcfe3226534d7cadeba5a549860d960db0", | ||||||
|  |         "version": 2, | ||||||
|  |         "locktime": 0, | ||||||
|  |         "vin": [ | ||||||
|  |             { | ||||||
|  |                 "txid": "cb8f206f4e88bec97107089f3e9e61d50cde53d4541992ae19759b71103cf75c", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "0014fd6d15ff832c12f1ff04a5ccd5039f7227b260bd", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 fd6d15ff832c12f1ff04a5ccd5039f7227b260bd", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1ql4k3tlur9sf0rlcy5hxd2qulwgnmyc9akehvth", | ||||||
|  |                     "value": 610677 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "304302205c430b36ebd2bb327951d83440af1f58f127871b2baada4c4dde2bc0b6721f56021f3445099f1a40e35baeda32e8e3727b505ffba0d882b11f498c7762f4184e9901", | ||||||
|  |                     "0236b5edd4fbbcfb045960e42ec8a9968944084785932e32940e8cd2583b37da67" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 2147483648 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "vout": [ | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "76a9149d32ef812385f3811634e0c0117dd153a5de10a488ac", | ||||||
|  |                 "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 9d32ef812385f3811634e0c0117dd153a5de10a4 OP_EQUALVERIFY OP_CHECKSIG", | ||||||
|  |                 "scriptpubkey_type": "p2pkh", | ||||||
|  |                 "scriptpubkey_address": "1FLC7Bag7okAkKPCyZbgZZg3Hh1EuGZ5Rd", | ||||||
|  |                 "value": 344697 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "00147dee8a7a38abbfb00dbfba365c8d6712934cc491", | ||||||
|  |                 "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 7dee8a7a38abbfb00dbfba365c8d6712934cc491", | ||||||
|  |                 "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                 "scriptpubkey_address": "bc1q0hhg573c4wlmqrdlhgm9ert8z2f5e3y3lf9hvx", | ||||||
|  |                 "value": 265396 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "size": 224, | ||||||
|  |         "weight": 572, | ||||||
|  |         "fee": 584, | ||||||
|  |         "status": { | ||||||
|  |             "confirmed": false | ||||||
|  |         }, | ||||||
|  |         "order": 2953680397, | ||||||
|  |         "vsize": 143, | ||||||
|  |         "adjustedVsize": 143, | ||||||
|  |         "sigops": 5, | ||||||
|  |         "feePerVsize": 4.083916083916084, | ||||||
|  |         "adjustedFeePerVsize": 4.083916083916084, | ||||||
|  |         "effectiveFeePerVsize": 4.083916083916084, | ||||||
|  |         "firstSeen": 1691222538, | ||||||
|  |         "uid": 526973, | ||||||
|  |         "inputs": [ | ||||||
|  |             526728 | ||||||
|  |         ], | ||||||
|  |         "position": { | ||||||
|  |             "block": 7, | ||||||
|  |             "vsize": 21429708.5 | ||||||
|  |         }, | ||||||
|  |         "bestDescendant": null, | ||||||
|  |         "cpfpChecked": true | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         "txid": "8e89b20f8a7fadb0e4cdbe57a00eee224f5076bac5387fc276916724e7c4a16a", | ||||||
|  |         "version": 2, | ||||||
|  |         "locktime": 800571, | ||||||
|  |         "vin": [ | ||||||
|  |             { | ||||||
|  |                 "txid": "35e16762459539f3a8e52c5dee6a9ccaa9e9268efed33aa2c6e1b7805e849f24", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "0014d4f16ef275b3e1c4a4ecbef55a164933e0f6460f", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 d4f16ef275b3e1c4a4ecbef55a164933e0f6460f", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1q6nckaun4k0suff8vhm6459jfx0s0v3s0ff4ukl", | ||||||
|  |                     "value": 1528924 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "3044022019008b26e885bb43da25a11ffac147a057722072eedb68411f114f6e7eb82ebc02201b618264bb97756b88fc3bbc365b73044ac18b33b1067e31cfd5bcd0f50ed2c701", | ||||||
|  |                     "039b71145070bd3e8af28e27fa577f2e12ab6bb4e212d3eeaef08b4bc39e8cbc13" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "txid": "67c27ed0f767526234bcd5f795a31fab8ec4d0251bf12c68f2746951f4110d90", | ||||||
|  |                 "vout": 3, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "0014a7c3d613b321375054b2ac9b6114367bc034ad6f", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 a7c3d613b321375054b2ac9b6114367bc034ad6f", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1q5lpavyanyym4q49j4jdkz9pk00qrftt0yqzvk3", | ||||||
|  |                     "value": 436523 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "304402204e67285fc656bc45ed082499b076d5dba2fa21d0d7e64a0ae52b19d69a11760002200f037d81ee540b74397844513b72b08ed92b06db76bd20b08f7a0a3b36ab13d501", | ||||||
|  |                     "02a3ebae85f0225b6fbb5ff060afce683a4683507a57544605a29ee7d287e591b4" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "txid": "21c38fb9a2521e438c614f53b19ddd7a5594bcc4b77480e762fd4b702fad3374", | ||||||
|  |                 "vout": 1, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "00149660e34ef88106536c816c037b5b28dd64a812e2", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 9660e34ef88106536c816c037b5b28dd64a812e2", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1qjeswxnhcsyr9xmypdsphkkegm4j2syhztgzxv4", | ||||||
|  |                     "value": 758149 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "3044022021b556f0aa99329076bcc435338aceaf534963efcab306931b1b2b0461e16e0c02203a78942a3745c4da656bddfd8cf16b85dc04d652904e88682127cdd9ca63339001", | ||||||
|  |                     "0298963be4a8f66aca9fcf1c6dc95547aeaa82347543190c91e094c2321142b9f0" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "txid": "aa998dbae65240a7386bf7d468459551d99c3de8e2f9057ff5f2d38e17daf788", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "00147bb7413a39943b21ded98ad5e6ad7a222d273e17", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 7bb7413a39943b21ded98ad5e6ad7a222d273e17", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1q0wm5zw3ejsajrhke3t27dtt6ygkjw0sh9lltg6", | ||||||
|  |                     "value": 1067200 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "304402205e2269f7d4ee0513b34354c38e920aef2dabac6f4350afb2dd105ff3ee43ae7b02202870322f2cb85cb0b2b0e38152f018bfff271dc3ec5aed0515854d0b259aaf3d01", | ||||||
|  |                     "03b87320cf3263a644a0d3f89c1b4a7304d9dfda9eb8c891560716abcb73e88b99" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "txid": "230253d195d779d4688ba16993985cd27b2e7a687d8b889b3bc63f19ece36f20", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "001439647bd997819d12dfc72b0fb9ff9ffcb84946f8", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 39647bd997819d12dfc72b0fb9ff9ffcb84946f8", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1q89j8hkvhsxw39h789v8mnlulljuyj3hc9zve97", | ||||||
|  |                     "value": 361950 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "304402204f7ca868bb9b92a07fecdc6b9dd56e4e1d007ca1405952f98ed6bc416345b5f2022055320a97791417abf6628fcf6513ac5785b06c630f854d8595e96ea06c3841d301", | ||||||
|  |                     "03a3ffe8e3ef2eea129b227e9658164bae0a6d21c17da6de9973ba34d9e04b21a0" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "txid": "670771e265a0b62dbd3c1fec2b865177eaf0bafd0ae49dd40a1c9fcd9a847a81", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "0014d45d1b0022c7387e42c5452ced561bdb8fd4b521", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 d45d1b0022c7387e42c5452ced561bdb8fd4b521", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1q63w3kqpzcuu8usk9g5kw64smmw8afdfpxmc2m0", | ||||||
|  |                     "value": 453275 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "3044022071312921800441903b2099e723add8702dd0f92ec11526ff87acf6967ec64cbd02203deabe7ed56d5daaa9a95c5a607b1ab705ff1c46bc6984a6dca120e63a91768601", | ||||||
|  |                     "0257302ac8d9c4c8f9b1744f19bb432359326b9cc7bdddeeab9202749a6d92be58" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "txid": "0af82159eee2b69242f2ff032636e410b67ec1ace52e55fb0d20ed814cd64803", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "001459e4d6bfefc6b45f955a69c4aeca26348e9d54ed", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 59e4d6bfefc6b45f955a69c4aeca26348e9d54ed", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1qt8jdd0l0c669l926d8z2aj3xxj8f648dtyn7tc", | ||||||
|  |                     "value": 439961 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "3044022027540322e92c23c5513aa2587e7feb56a8ce82f879269d6b3cbd425634b44f8e022045572dee7262b02130bfe32d8aa8abbfaa64e101abfc819bba5380c78876692d01", | ||||||
|  |                     "03fe02262d87f4a5289d3dd66e3d9a74cd49fa1cad0249284a7451896a827249a5" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "txid": "68cf9c784870a4f888f044755f7ce318557f652461db8ef887d279672f186018", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "001454822b2d5d52597a78b630921cf439a41e32f2f9", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 54822b2d5d52597a78b630921cf439a41e32f2f9", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1q2jpzkt2a2fvh579kxzfpeape5s0r9uhewhl5n4", | ||||||
|  |                     "value": 227639 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "304402203ad511d6a8730748b8828bc38897d360451adf620ebdc1d229c08c097c80bef202202f50c793d95b5200cf2258e03896a3be7720df0eb3b8c810c86db74341a7e83e01", | ||||||
|  |                     "0294992e9f4546e6e119741f908411ae531e9d1ff732d69b4dff8172aaf2a4b216" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "txid": "793f01dfdb19bf41f958fd917c16d9c4dd5d5e1a5c0434bfdb367212659d1b5b", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "0014f54edf8ae647b5300e2674523254e923d93d169f", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f54edf8ae647b5300e2674523254e923d93d169f", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1q748dlzhxg76nqr3xw3fry48fy0vn695lvhlkxv", | ||||||
|  |                     "value": 227070 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "304402206e807ab616f4f2887ba703ae744d856142d9aca8128698419bbb67fb4fad8177022060fc65c7cd66baa88ad1e1d317a6edd5f6cb52fe8bff6e5405ffa1acf9d945d901", | ||||||
|  |                     "02a0ad0167c6e9edf62677404d74d3b80ea276e47e758ffaa6ca17bd65ac79f7aa" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "vout": [ | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "00148a5c45ccfc29d209940d94525e2edb7743a1ad8a", | ||||||
|  |                 "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 8a5c45ccfc29d209940d94525e2edb7743a1ad8a", | ||||||
|  |                 "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                 "scriptpubkey_address": "bc1q3fwytn8u98fqn9qdj3f9utkmwap6rtv2ym33zm", | ||||||
|  |                 "value": 5500000 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "size": 1375, | ||||||
|  |         "weight": 2605, | ||||||
|  |         "fee": 691, | ||||||
|  |         "status": { | ||||||
|  |             "confirmed": false | ||||||
|  |         }, | ||||||
|  |         "order": 1788986599, | ||||||
|  |         "vsize": 651, | ||||||
|  |         "adjustedVsize": 651.25, | ||||||
|  |         "sigops": 9, | ||||||
|  |         "feePerVsize": 1.0610364683301343, | ||||||
|  |         "adjustedFeePerVsize": 1.0610364683301343, | ||||||
|  |         "effectiveFeePerVsize": 1.0610364683301343, | ||||||
|  |         "firstSeen": 1691163298, | ||||||
|  |         "uid": 120494, | ||||||
|  |         "inputs": [], | ||||||
|  |         "position": { | ||||||
|  |             "block": 7, | ||||||
|  |             "vsize": 93780091.5 | ||||||
|  |         }, | ||||||
|  |         "bestDescendant": null, | ||||||
|  |         "cpfpChecked": true | ||||||
|  |     } | ||||||
|  | ] | ||||||
							
								
								
									
										121
									
								
								backend/src/__tests__/api/test-data/transactions-rbfs.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								backend/src/__tests__/api/test-data/transactions-rbfs.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,121 @@ | |||||||
|  | [ | ||||||
|  |     { | ||||||
|  |         "txid": "7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6", | ||||||
|  |         "version": 1, | ||||||
|  |         "locktime": 0, | ||||||
|  |         "vin": [ | ||||||
|  |             { | ||||||
|  |                 "txid": "d863deb706de5a611028f7547e16ea81d7819e44beb640fb30a9ba30c585140f", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "76a914cd5b6566b455d043558829f6932edaae5d8f0ad388ac", | ||||||
|  |                     "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 cd5b6566b455d043558829f6932edaae5d8f0ad3 OP_EQUALVERIFY OP_CHECKSIG", | ||||||
|  |                     "scriptpubkey_type": "p2pkh", | ||||||
|  |                     "scriptpubkey_address": "1Kiq1dyVBzYLWGrBPWjChvKyzB2H95x5RJ", | ||||||
|  |                     "value": 799995000 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "483045022100aeeddfb9785c5a4b70e90d0445785c68b7a44e28853441134a70ddc4da39527602203dfe1ec1a377aaacb64ae65c7c944caf1398d2dc063f712251b4cf696d44d3cb01210314338e3e191aea3ac9e9292611faeedf0379bbe62c30fd76c7450722a1ac47c6", | ||||||
|  |                 "scriptsig_asm": "OP_PUSHBYTES_72 3045022100aeeddfb9785c5a4b70e90d0445785c68b7a44e28853441134a70ddc4da39527602203dfe1ec1a377aaacb64ae65c7c944caf1398d2dc063f712251b4cf696d44d3cb01 OP_PUSHBYTES_33 0314338e3e191aea3ac9e9292611faeedf0379bbe62c30fd76c7450722a1ac47c6", | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "vout": [ | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "6a4c5058325b8669baa9259e082f064005bc92274b559337ac317798f5d76f2d0577ed5a96042fce8c33d841b6c47a99f9597000ab04a10b34cd419fc19784d9e36f1a33fd7b000c3bce00b6000c1d1e00614b", | ||||||
|  |                 "scriptpubkey_asm": "OP_RETURN OP_PUSHDATA1 58325b8669baa9259e082f064005bc92274b559337ac317798f5d76f2d0577ed5a96042fce8c33d841b6c47a99f9597000ab04a10b34cd419fc19784d9e36f1a33fd7b000c3bce00b6000c1d1e00614b", | ||||||
|  |                 "scriptpubkey_type": "op_return", | ||||||
|  |                 "value": 0 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "a9144890aae025c84cb72a9730b49ca12595d6f6088d87", | ||||||
|  |                 "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 4890aae025c84cb72a9730b49ca12595d6f6088d OP_EQUAL", | ||||||
|  |                 "scriptpubkey_type": "p2sh", | ||||||
|  |                 "scriptpubkey_address": "38Jht2bzmJL4EwoFvvyFzejhfEb4J7KxLb", | ||||||
|  |                 "value": 155000 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "76a91486e7dad6617303942a448b7f8afe9653e5624a5e88ac", | ||||||
|  |                 "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 86e7dad6617303942a448b7f8afe9653e5624a5e OP_EQUALVERIFY OP_CHECKSIG", | ||||||
|  |                 "scriptpubkey_type": "p2pkh", | ||||||
|  |                 "scriptpubkey_address": "1DJKJGApgX4W8BSQ8FRPLqX78UaCskT4r2", | ||||||
|  |                 "value": 155000 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "76a914cd5b6566b455d043558829f6932edaae5d8f0ad388ac", | ||||||
|  |                 "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 cd5b6566b455d043558829f6932edaae5d8f0ad3 OP_EQUALVERIFY OP_CHECKSIG", | ||||||
|  |                 "scriptpubkey_type": "p2pkh", | ||||||
|  |                 "scriptpubkey_address": "1Kiq1dyVBzYLWGrBPWjChvKyzB2H95x5RJ", | ||||||
|  |                 "value": 799675549 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "size": 350, | ||||||
|  |         "weight": 1400, | ||||||
|  |         "fee": 9451, | ||||||
|  |         "status": { | ||||||
|  |             "confirmed": false | ||||||
|  |         }, | ||||||
|  |         "order": 2798688215, | ||||||
|  |         "vsize": 350, | ||||||
|  |         "adjustedVsize": 350, | ||||||
|  |         "sigops": 8, | ||||||
|  |         "feePerVsize": 27.002857142857142, | ||||||
|  |         "adjustedFeePerVsize": 27.002857142857142, | ||||||
|  |         "effectiveFeePerVsize": 27.002857142857142, | ||||||
|  |         "firstSeen": 1691218536, | ||||||
|  |         "uid": 513598, | ||||||
|  |         "inputs": [], | ||||||
|  |         "position": { | ||||||
|  |             "block": 0, | ||||||
|  |             "vsize": 22166 | ||||||
|  |         }, | ||||||
|  |         "bestDescendant": null, | ||||||
|  |         "cpfpChecked": true | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         "txid": "5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875", | ||||||
|  |         "version": 2, | ||||||
|  |         "locktime": 0, | ||||||
|  |         "vin": [ | ||||||
|  |             { | ||||||
|  |                 "txid": "b50225a04a1d6fbbfa7a2122bc0580396f614027b3957f476229633576f06130", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "0014a24f913f8a9c30a4c302c2c78f2fd7addb08fd07", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 a24f913f8a9c30a4c302c2c78f2fd7addb08fd07", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1q5f8ez0u2nsc2fsczctrc7t7h4hds3lg82ewqhz", | ||||||
|  |                     "value": 612917 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "3045022100a0c23953ace5d022b7a6d45d1ae1730bf20a4d594bb5d4fa7aa80e4881b44d320220008f9b144805bb91995fc0f452a56e09f4ad16fa149d71ae9b5d57c742e8e2cc01", | ||||||
|  |                     "03dc2c7b687019b40a68d713322675206cc266e34e5340ec982c13ff0222c3b2b6" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 2147483649 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "vout": [ | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "0014199a98f9589364ffe5ef5bbae45ce5dfcbb873bd", | ||||||
|  |                 "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 199a98f9589364ffe5ef5bbae45ce5dfcbb873bd", | ||||||
|  |                 "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                 "scriptpubkey_address": "bc1qrxdf372cjdj0le00twawgh89ml9msuaau62gk4", | ||||||
|  |                 "value": 611909 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "size": 192, | ||||||
|  |         "weight": 438, | ||||||
|  |         "fee": 1008, | ||||||
|  |         "status": { | ||||||
|  |             "confirmed": false | ||||||
|  |         }, | ||||||
|  |         "bestDescendant": null, | ||||||
|  |         "descendants": null, | ||||||
|  |         "adjustedFeePerVsize": 10.2283, | ||||||
|  |         "sigops": 1, | ||||||
|  |         "adjustedVsize": 109.5 | ||||||
|  |     } | ||||||
|  | ] | ||||||
							
								
								
									
										139
									
								
								backend/src/__tests__/api/test-data/transactions-replaced.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								backend/src/__tests__/api/test-data/transactions-replaced.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,139 @@ | |||||||
|  | [ | ||||||
|  |     { | ||||||
|  |         "txid": "008592364e21c1e3d62ba9538ac78a81779897b52100af5707ab063df98964f2", | ||||||
|  |         "version": 1, | ||||||
|  |         "locktime": 0, | ||||||
|  |         "vin": [ | ||||||
|  |             { | ||||||
|  |                 "txid": "d863deb706de5a611028f7547e16ea81d7819e44beb640fb30a9ba30c585140f", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "76a914cd5b6566b455d043558829f6932edaae5d8f0ad388ac", | ||||||
|  |                     "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 cd5b6566b455d043558829f6932edaae5d8f0ad3 OP_EQUALVERIFY OP_CHECKSIG", | ||||||
|  |                     "scriptpubkey_type": "p2pkh", | ||||||
|  |                     "scriptpubkey_address": "1Kiq1dyVBzYLWGrBPWjChvKyzB2H95x5RJ", | ||||||
|  |                     "value": 799995000 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "483045022100c1fb331d155a7d299a0451d14fa1122b328e0e239afc9ba8dc2aff449ddc5a3a02201c1e19030d1efa432f5069cd369d7ad09a67f68501345e4db35f7b799605f55601210314338e3e191aea3ac9e9292611faeedf0379bbe62c30fd76c7450722a1ac47c6", | ||||||
|  |                 "scriptsig_asm": "OP_PUSHBYTES_72 3045022100c1fb331d155a7d299a0451d14fa1122b328e0e239afc9ba8dc2aff449ddc5a3a02201c1e19030d1efa432f5069cd369d7ad09a67f68501345e4db35f7b799605f55601 OP_PUSHBYTES_33 0314338e3e191aea3ac9e9292611faeedf0379bbe62c30fd76c7450722a1ac47c6", | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 4294967293 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "vout": [ | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "6a4c5058325b78064160b631b5a15d9078d99c0db066449fb4c59bbfa4d987ba906e2990088b2fce8c33d841b6c47a99f9597000ab04a10b34cd419fc19784d9e36f1a33fd7b000c3bce00b6000c1d1e00614b", | ||||||
|  |                 "scriptpubkey_asm": "OP_RETURN OP_PUSHDATA1 58325b78064160b631b5a15d9078d99c0db066449fb4c59bbfa4d987ba906e2990088b2fce8c33d841b6c47a99f9597000ab04a10b34cd419fc19784d9e36f1a33fd7b000c3bce00b6000c1d1e00614b", | ||||||
|  |                 "scriptpubkey_type": "op_return", | ||||||
|  |                 "value": 0 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "a9144890aae025c84cb72a9730b49ca12595d6f6088d87", | ||||||
|  |                 "scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 4890aae025c84cb72a9730b49ca12595d6f6088d OP_EQUAL", | ||||||
|  |                 "scriptpubkey_type": "p2sh", | ||||||
|  |                 "scriptpubkey_address": "38Jht2bzmJL4EwoFvvyFzejhfEb4J7KxLb", | ||||||
|  |                 "value": 155000 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "76a91486e7dad6617303942a448b7f8afe9653e5624a5e88ac", | ||||||
|  |                 "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 86e7dad6617303942a448b7f8afe9653e5624a5e OP_EQUALVERIFY OP_CHECKSIG", | ||||||
|  |                 "scriptpubkey_type": "p2pkh", | ||||||
|  |                 "scriptpubkey_address": "1DJKJGApgX4W8BSQ8FRPLqX78UaCskT4r2", | ||||||
|  |                 "value": 155000 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "76a914cd5b6566b455d043558829f6932edaae5d8f0ad388ac", | ||||||
|  |                 "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 cd5b6566b455d043558829f6932edaae5d8f0ad3 OP_EQUALVERIFY OP_CHECKSIG", | ||||||
|  |                 "scriptpubkey_type": "p2pkh", | ||||||
|  |                 "scriptpubkey_address": "1Kiq1dyVBzYLWGrBPWjChvKyzB2H95x5RJ", | ||||||
|  |                 "value": 799676250 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "size": 350, | ||||||
|  |         "weight": 1400, | ||||||
|  |         "fee": 8750, | ||||||
|  |         "status": { | ||||||
|  |             "confirmed": false | ||||||
|  |         }, | ||||||
|  |         "order": 4066675193, | ||||||
|  |         "vsize": 350, | ||||||
|  |         "adjustedVsize": 350, | ||||||
|  |         "sigops": 8, | ||||||
|  |         "feePerVsize": 25, | ||||||
|  |         "adjustedFeePerVsize": 25, | ||||||
|  |         "effectiveFeePerVsize": 25, | ||||||
|  |         "firstSeen": 1691218516, | ||||||
|  |         "uid": 512584, | ||||||
|  |         "inputs": [], | ||||||
|  |         "position": { | ||||||
|  |             "block": 0, | ||||||
|  |             "vsize": 13846 | ||||||
|  |         }, | ||||||
|  |         "bestDescendant": null, | ||||||
|  |         "cpfpChecked": true | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         "txid": "b7981a624e4261c11f1246314d41e74be56af82eb557bcd054a5e0f94c023668", | ||||||
|  |         "version": 2, | ||||||
|  |         "locktime": 0, | ||||||
|  |         "vin": [ | ||||||
|  |             { | ||||||
|  |                 "txid": "b50225a04a1d6fbbfa7a2122bc0580396f614027b3957f476229633576f06130", | ||||||
|  |                 "vout": 0, | ||||||
|  |                 "prevout": { | ||||||
|  |                     "scriptpubkey": "0014a24f913f8a9c30a4c302c2c78f2fd7addb08fd07", | ||||||
|  |                     "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 a24f913f8a9c30a4c302c2c78f2fd7addb08fd07", | ||||||
|  |                     "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                     "scriptpubkey_address": "bc1q5f8ez0u2nsc2fsczctrc7t7h4hds3lg82ewqhz", | ||||||
|  |                     "value": 612917 | ||||||
|  |                 }, | ||||||
|  |                 "scriptsig": "", | ||||||
|  |                 "scriptsig_asm": "", | ||||||
|  |                 "witness": [ | ||||||
|  |                     "304402204dd10f14afa41bc76d8278140ff1ec3d3f87f2c207bbb5418cc76dab30d7f6a402207877cc9c6a2c724b6ea7a1c24ac00022469f194fd1a4bd8030bbca1787d3f5f301", | ||||||
|  |                     "03dc2c7b687019b40a68d713322675206cc266e34e5340ec982c13ff0222c3b2b6" | ||||||
|  |                 ], | ||||||
|  |                 "is_coinbase": false, | ||||||
|  |                 "sequence": 2147483648 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "vout": [ | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "76a9149d32ef812385f3811634e0c0117dd153a5de10a488ac", | ||||||
|  |                 "scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 9d32ef812385f3811634e0c0117dd153a5de10a4 OP_EQUALVERIFY OP_CHECKSIG", | ||||||
|  |                 "scriptpubkey_type": "p2pkh", | ||||||
|  |                 "scriptpubkey_address": "1FLC7Bag7okAkKPCyZbgZZg3Hh1EuGZ5Rd", | ||||||
|  |                 "value": 344697 | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "scriptpubkey": "00144c2671336ca8761863b4c68d64d4672491fec1b9", | ||||||
|  |                 "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 4c2671336ca8761863b4c68d64d4672491fec1b9", | ||||||
|  |                 "scriptpubkey_type": "v0_p2wpkh", | ||||||
|  |                 "scriptpubkey_address": "bc1qfsn8zvmv4pmpsca5c6xkf4r8yjglasdesrawcx", | ||||||
|  |                 "value": 267636 | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "size": 225, | ||||||
|  |         "weight": 573, | ||||||
|  |         "fee": 584, | ||||||
|  |         "status": { | ||||||
|  |             "confirmed": false | ||||||
|  |         }, | ||||||
|  |         "order": 1748369996, | ||||||
|  |         "vsize": 143, | ||||||
|  |         "adjustedVsize": 143.25, | ||||||
|  |         "sigops": 5, | ||||||
|  |         "feePerVsize": 4.076788830715532, | ||||||
|  |         "adjustedFeePerVsize": 4.076788830715532, | ||||||
|  |         "effectiveFeePerVsize": 4.076788830715532, | ||||||
|  |         "firstSeen": 1691222376, | ||||||
|  |         "uid": 526515, | ||||||
|  |         "inputs": [], | ||||||
|  |         "position": { | ||||||
|  |             "block": 7, | ||||||
|  |             "vsize": 22021095.5 | ||||||
|  |         }, | ||||||
|  |         "bestDescendant": null, | ||||||
|  |         "cpfpChecked": true | ||||||
|  |     } | ||||||
|  | ] | ||||||
| @ -52,7 +52,12 @@ describe('Mempool Backend Config', () => { | |||||||
| 
 | 
 | ||||||
|       expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); |       expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true }); | ||||||
| 
 | 
 | ||||||
|       expect(config.ESPLORA).toStrictEqual({ REST_API_URL: 'http://127.0.0.1:3000', UNIX_SOCKET_PATH: null, RETRY_UNIX_SOCKET_AFTER: 30000 }); |       expect(config.ESPLORA).toStrictEqual({ | ||||||
|  |         REST_API_URL: 'http://127.0.0.1:3000', | ||||||
|  |         UNIX_SOCKET_PATH: null, | ||||||
|  |         RETRY_UNIX_SOCKET_AFTER: 30000, | ||||||
|  |         FALLBACK: [], | ||||||
|  |        }); | ||||||
| 
 | 
 | ||||||
|       expect(config.CORE_RPC).toStrictEqual({ |       expect(config.CORE_RPC).toStrictEqual({ | ||||||
|         HOST: '127.0.0.1', |         HOST: '127.0.0.1', | ||||||
| @ -181,41 +186,50 @@ describe('Mempool Backend Config', () => { | |||||||
|         for (const [key, value] of Object.entries(jsonObj)) { |         for (const [key, value] of Object.entries(jsonObj)) { | ||||||
|           // We have a few cases where we can't follow the pattern
 |           // We have a few cases where we can't follow the pattern
 | ||||||
|           if (root === 'MEMPOOL' && key === 'HTTP_PORT') { |           if (root === 'MEMPOOL' && key === 'HTTP_PORT') { | ||||||
|             console.log('skipping check for MEMPOOL_HTTP_PORT'); |             if (process.env.CI) { | ||||||
|  |               console.log('skipping check for MEMPOOL_HTTP_PORT'); | ||||||
|  |             } | ||||||
|             continue; |             continue; | ||||||
|           } |           } | ||||||
|           switch (typeof value) { | 
 | ||||||
|             case 'object': { |           if (root) { | ||||||
|               if (Array.isArray(value)) { |  | ||||||
|                 continue; |  | ||||||
|               } else { |  | ||||||
|                 parseJson(value, key); |  | ||||||
|               } |  | ||||||
|               break; |  | ||||||
|             } |  | ||||||
|             default: { |  | ||||||
|               //The flattened string, i.e, __MEMPOOL_ENABLED__
 |               //The flattened string, i.e, __MEMPOOL_ENABLED__
 | ||||||
|               const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`; |               const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`; | ||||||
| 
 | 
 | ||||||
|               //The string used as the environment variable, i.e, MEMPOOL_ENABLED
 |               //The string used as the environment variable, i.e, MEMPOOL_ENABLED
 | ||||||
|               const envVarStr = `${root ? root : ''}_${key}`; |               const envVarStr = `${root ? root : ''}_${key}`; | ||||||
| 
 | 
 | ||||||
|  |               let defaultEntry; | ||||||
|               //The string used as the default value, to be checked as a regex, i.e, __MEMPOOL_ENABLED__=${MEMPOOL_ENABLED:=(.*)}
 |               //The string used as the default value, to be checked as a regex, i.e, __MEMPOOL_ENABLED__=${MEMPOOL_ENABLED:=(.*)}
 | ||||||
|               const defaultEntry = replaceStr + '=' + '\\${' + envVarStr + ':=(.*)' + '}'; |               if (Array.isArray(value)) { | ||||||
| 
 |                 defaultEntry = `${replaceStr}=\${${envVarStr}:=[]}`; | ||||||
|               console.log(`looking for ${defaultEntry} in the start.sh script`); |                 if (process.env.CI) { | ||||||
|               const re = new RegExp(defaultEntry); |                   console.log(`looking for ${defaultEntry} in the start.sh script`); | ||||||
|               expect(startSh).toMatch(re); |                 } | ||||||
|  |                 //Regex matching does not work with the array values
 | ||||||
|  |                 expect(startSh).toContain(defaultEntry); | ||||||
|  |               } else { | ||||||
|  |                  defaultEntry = replaceStr + '=' + '\\${' + envVarStr + ':=(.*)' + '}'; | ||||||
|  |                  if (process.env.CI) { | ||||||
|  |                   console.log(`looking for ${defaultEntry} in the start.sh script`); | ||||||
|  |                 } | ||||||
|  |                 const re = new RegExp(defaultEntry); | ||||||
|  |                 expect(startSh).toMatch(re); | ||||||
|  |               } | ||||||
| 
 | 
 | ||||||
|               //The string that actually replaces the values in the config file
 |               //The string that actually replaces the values in the config file
 | ||||||
|               const sedStr = 'sed -i "s!' + replaceStr + '!${' + replaceStr + '}!g" mempool-config.json'; |               const sedStr = 'sed -i "s!' + replaceStr + '!${' + replaceStr + '}!g" mempool-config.json'; | ||||||
|               console.log(`looking for ${sedStr} in the start.sh script`); |               if (process.env.CI) { | ||||||
|  |                 console.log(`looking for ${sedStr} in the start.sh script`); | ||||||
|  |               } | ||||||
|               expect(startSh).toContain(sedStr); |               expect(startSh).toContain(sedStr); | ||||||
|               break; |  | ||||||
|             } |             } | ||||||
|  |           else { | ||||||
|  |             parseJson(value, key); | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|       parseJson(fixture); |       parseJson(fixture); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  | |||||||
| @ -23,6 +23,8 @@ export interface AbstractBitcoinApi { | |||||||
|   $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>; |   $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>; | ||||||
|   $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>; |   $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>; | ||||||
|   $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>; |   $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>; | ||||||
|  | 
 | ||||||
|  |   startHealthChecks(): void; | ||||||
| } | } | ||||||
| export interface BitcoinRpcCredentials { | export interface BitcoinRpcCredentials { | ||||||
|   host: string; |   host: string; | ||||||
|  | |||||||
| @ -355,6 +355,7 @@ class BitcoinApi implements AbstractBitcoinApi { | |||||||
|     return transaction; |     return transaction; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   public startHealthChecks(): void {}; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default BitcoinApi; | export default BitcoinApi; | ||||||
|  | |||||||
| @ -1,135 +1,260 @@ | |||||||
| import config from '../../config'; | import config from '../../config'; | ||||||
| import axios, { AxiosRequestConfig } from 'axios'; | import axios, { AxiosResponse } from 'axios'; | ||||||
| import http from 'http'; | import http from 'http'; | ||||||
| import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; | import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; | ||||||
| import { IEsploraApi } from './esplora-api.interface'; | import { IEsploraApi } from './esplora-api.interface'; | ||||||
| import logger from '../../logger'; | import logger from '../../logger'; | ||||||
| 
 | 
 | ||||||
| const axiosConnection = axios.create({ | interface FailoverHost { | ||||||
|   httpAgent: new http.Agent({ keepAlive: true, }) |   host: string, | ||||||
| }); |   rtts: number[], | ||||||
|  |   rtt: number | ||||||
|  |   failures: number, | ||||||
|  |   socket?: boolean, | ||||||
|  |   outOfSync?: boolean, | ||||||
|  |   unreachable?: boolean, | ||||||
|  |   preferred?: boolean, | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| class ElectrsApi implements AbstractBitcoinApi { | class FailoverRouter { | ||||||
|   private axiosConfigWithUnixSocket: AxiosRequestConfig = config.ESPLORA.UNIX_SOCKET_PATH ? { |   activeHost: FailoverHost; | ||||||
|     socketPath: config.ESPLORA.UNIX_SOCKET_PATH, |   fallbackHost: FailoverHost; | ||||||
|     timeout: 10000, |   hosts: FailoverHost[]; | ||||||
|   } : { |   multihost: boolean; | ||||||
|     timeout: 10000, |   pollInterval: number = 60000; | ||||||
|   }; |   pollTimer: NodeJS.Timeout | null = null; | ||||||
|   private axiosConfigTcpSocketOnly: AxiosRequestConfig = { |   pollConnection = axios.create(); | ||||||
|     timeout: 10000, |   requestConnection = axios.create({ | ||||||
|   }; |     httpAgent: new http.Agent({ keepAlive: true }) | ||||||
| 
 |   }); | ||||||
|   unixSocketRetryTimeout; |  | ||||||
|   activeAxiosConfig; |  | ||||||
| 
 | 
 | ||||||
|   constructor() { |   constructor() { | ||||||
|     this.activeAxiosConfig = this.axiosConfigWithUnixSocket; |     // setup list of hosts
 | ||||||
|  |     this.hosts = (config.ESPLORA.FALLBACK || []).map(domain => { | ||||||
|  |       return { | ||||||
|  |         host: domain, | ||||||
|  |         rtts: [], | ||||||
|  |         rtt: Infinity, | ||||||
|  |         failures: 0, | ||||||
|  |       }; | ||||||
|  |     }); | ||||||
|  |     this.activeHost = { | ||||||
|  |       host: config.ESPLORA.UNIX_SOCKET_PATH || config.ESPLORA.REST_API_URL, | ||||||
|  |       rtts: [], | ||||||
|  |       rtt: 0, | ||||||
|  |       failures: 0, | ||||||
|  |       socket: !!config.ESPLORA.UNIX_SOCKET_PATH, | ||||||
|  |       preferred: true, | ||||||
|  |     }; | ||||||
|  |     this.fallbackHost = this.activeHost; | ||||||
|  |     this.hosts.unshift(this.activeHost); | ||||||
|  |     this.multihost = this.hosts.length > 1; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   fallbackToTcpSocket() { |   public startHealthChecks(): void { | ||||||
|     if (!this.unixSocketRetryTimeout) { |     // use axios interceptors to measure request rtt
 | ||||||
|       logger.err(`Unable to connect to esplora unix socket. Falling back to tcp socket. Retrying unix socket in ${config.ESPLORA.RETRY_UNIX_SOCKET_AFTER / 1000} seconds`); |     this.pollConnection.interceptors.request.use((config) => { | ||||||
|       // Retry the unix socket after a few seconds
 |       config['meta'] = { startTime: Date.now() }; | ||||||
|       this.unixSocketRetryTimeout = setTimeout(() => { |       return config; | ||||||
|         logger.info(`Retrying to use unix socket for esplora now (applied for the next query)`); |     }); | ||||||
|         this.activeAxiosConfig = this.axiosConfigWithUnixSocket; |     this.pollConnection.interceptors.response.use((response) => { | ||||||
|         this.unixSocketRetryTimeout = undefined; |       response.config['meta'].rtt = Date.now() - response.config['meta'].startTime; | ||||||
|       }, config.ESPLORA.RETRY_UNIX_SOCKET_AFTER); |       return response; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if (this.multihost) { | ||||||
|  |       this.pollHosts(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // start polling hosts to measure availability & rtt
 | ||||||
|  |   private async pollHosts(): Promise<void> { | ||||||
|  |     if (this.pollTimer) { | ||||||
|  |       clearTimeout(this.pollTimer); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Use the TCP socket (reach a different esplora instance through nginx)
 |     const results = await Promise.allSettled(this.hosts.map(async (host) => { | ||||||
|     this.activeAxiosConfig = this.axiosConfigTcpSocketOnly; |       if (host.socket) { | ||||||
|  |         return this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: 5000 }); | ||||||
|  |       } else { | ||||||
|  |         return this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: 5000 }); | ||||||
|  |       } | ||||||
|  |     })); | ||||||
|  |     const maxHeight = results.reduce((max, result) => Math.max(max, result.status === 'fulfilled' ? result.value?.data || 0 : 0), 0); | ||||||
|  | 
 | ||||||
|  |     // 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; | ||||||
|  |         if (height == null || isNaN(height) || (maxHeight - height > 2)) { | ||||||
|  |           host.outOfSync = true; | ||||||
|  |         } else { | ||||||
|  |           host.outOfSync = false; | ||||||
|  |         } | ||||||
|  |         host.unreachable = false; | ||||||
|  |       } else { | ||||||
|  |         host.unreachable = true; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.sortHosts(); | ||||||
|  | 
 | ||||||
|  |     logger.debug(`Tomahawk ranking: ${this.hosts.map(host => '\navg rtt ' + Math.round(host.rtt).toString().padStart(5, ' ') + ' | reachable? ' + (!host.unreachable || false).toString().padStart(5, ' ') + ' | in sync? ' + (!host.outOfSync || false).toString().padStart(5, ' ') + ` | ${host.host}`).join('')}`); | ||||||
|  | 
 | ||||||
|  |     // 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); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $queryWrapper<T>(url, responseType = 'json'): Promise<T> { |   // sort hosts by connection quality, and update default fallback
 | ||||||
|     return axiosConnection.get<T>(url, { ...this.activeAxiosConfig, responseType: responseType }) |   private sortHosts(): void { | ||||||
|       .then((response) => response.data) |     // sort by connection quality
 | ||||||
|  |     this.hosts.sort((a, b) => { | ||||||
|  |       if ((a.unreachable || a.outOfSync) === (b.unreachable || b.outOfSync)) { | ||||||
|  |         if  (a.preferred === b.preferred) { | ||||||
|  |           // lower rtt is best
 | ||||||
|  |           return a.rtt - b.rtt; | ||||||
|  |         } else { // unless we have a preferred host
 | ||||||
|  |           return a.preferred ? -1 : 1; | ||||||
|  |         } | ||||||
|  |       } else { // or the host is out of sync
 | ||||||
|  |         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]; | ||||||
|  |     logger.warn(`Switching esplora host to ${this.activeHost.host}`); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private addFailure(host: FailoverHost): FailoverHost { | ||||||
|  |     host.failures++; | ||||||
|  |     if (host.failures > 5 && this.multihost) { | ||||||
|  |       logger.warn(`Too many esplora failures on ${this.activeHost.host}, falling back to next best alternative`); | ||||||
|  |       this.electHost(); | ||||||
|  |       return this.activeHost; | ||||||
|  |     } else { | ||||||
|  |       return this.fallbackHost; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private async $query<T>(method: 'get'| 'post', path, data: any, responseType = 'json', host = this.activeHost, retry: boolean = true): Promise<T> { | ||||||
|  |     let axiosConfig; | ||||||
|  |     let url; | ||||||
|  |     if (host.socket) { | ||||||
|  |       axiosConfig = { socketPath: host.host, timeout: 10000, responseType }; | ||||||
|  |       url = path; | ||||||
|  |     } else { | ||||||
|  |       axiosConfig = { timeout: 10000, responseType }; | ||||||
|  |       url = host.host + path; | ||||||
|  |     } | ||||||
|  |     return (method === 'post' | ||||||
|  |         ? this.requestConnection.post<T>(url, data, axiosConfig) | ||||||
|  |         : this.requestConnection.get<T>(url, axiosConfig) | ||||||
|  |     ).then((response) => { host.failures = Math.max(0, host.failures - 1); return response.data; }) | ||||||
|       .catch((e) => { |       .catch((e) => { | ||||||
|         if (e?.code === 'ECONNREFUSED') { |         let fallbackHost = this.fallbackHost; | ||||||
|           this.fallbackToTcpSocket(); |         if (e?.response?.status !== 404) { | ||||||
|  |           logger.warn(`esplora request failed ${e?.response?.status || 500} ${host.host}${path}`); | ||||||
|  |           fallbackHost = this.addFailure(host); | ||||||
|  |         } | ||||||
|  |         if (retry && e?.code === 'ECONNREFUSED' && this.multihost) { | ||||||
|           // Retry immediately
 |           // Retry immediately
 | ||||||
|           return axiosConnection.get<T>(url, this.activeAxiosConfig) |           return this.$query(method, path, data, responseType, fallbackHost, false); | ||||||
|             .then((response) => response.data) |  | ||||||
|             .catch((e) => { |  | ||||||
|               logger.warn(`Cannot query esplora through the unix socket nor the tcp socket. Exception ${e}`); |  | ||||||
|               throw e; |  | ||||||
|             }); |  | ||||||
|         } else { |         } else { | ||||||
|           throw e; |           throw e; | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $postWrapper<T>(url, body, responseType = 'json', params: any = undefined): Promise<T> { |   public async $get<T>(path, responseType = 'json'): Promise<T> { | ||||||
|     return axiosConnection.post<T>(url, body, { ...this.activeAxiosConfig, responseType: responseType, params }) |     return this.$query<T>('get', path, null, responseType); | ||||||
|       .then((response) => response.data) |  | ||||||
|       .catch((e) => { |  | ||||||
|         if (e?.code === 'ECONNREFUSED') { |  | ||||||
|           this.fallbackToTcpSocket(); |  | ||||||
|           // Retry immediately
 |  | ||||||
|           return axiosConnection.post<T>(url, body, this.activeAxiosConfig) |  | ||||||
|             .then((response) => response.data) |  | ||||||
|             .catch((e) => { |  | ||||||
|               logger.warn(`Cannot query esplora through the unix socket nor the tcp socket. Exception ${e}`); |  | ||||||
|               throw e; |  | ||||||
|             }); |  | ||||||
|         } else { |  | ||||||
|           throw e; |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   public async $post<T>(path, data: any, responseType = 'json'): Promise<T> { | ||||||
|  |     return this.$query<T>('post', path, data, responseType); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class ElectrsApi implements AbstractBitcoinApi { | ||||||
|  |   private failoverRouter = new FailoverRouter(); | ||||||
|  | 
 | ||||||
|   $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> { |   $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> { | ||||||
|     return this.$queryWrapper<IEsploraApi.Transaction['txid'][]>(config.ESPLORA.REST_API_URL + '/mempool/txids'); |     return this.failoverRouter.$get<IEsploraApi.Transaction['txid'][]>('/mempool/txids'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> { |   $getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> { | ||||||
|     return this.$queryWrapper<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId); |     return this.failoverRouter.$get<IEsploraApi.Transaction>('/tx/' + txId); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async $getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> { |   async $getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> { | ||||||
|     return this.$postWrapper<IEsploraApi.Transaction[]>(config.ESPLORA.REST_API_URL + '/mempool/txs', txids, 'json'); |     return this.failoverRouter.$post<IEsploraApi.Transaction[]>('/mempool/txs', txids, 'json'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async $getAllMempoolTransactions(lastSeenTxid?: string): Promise<IEsploraApi.Transaction[]> { |   async $getAllMempoolTransactions(lastSeenTxid?: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|     return this.$queryWrapper<IEsploraApi.Transaction[]>(config.ESPLORA.REST_API_URL + '/mempool/txs' + (lastSeenTxid ? '/' + lastSeenTxid : '')); |     return this.failoverRouter.$get<IEsploraApi.Transaction[]>('/mempool/txs' + (lastSeenTxid ? '/' + lastSeenTxid : '')); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getTransactionHex(txId: string): Promise<string> { |   $getTransactionHex(txId: string): Promise<string> { | ||||||
|     return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex'); |     return this.failoverRouter.$get<string>('/tx/' + txId + '/hex'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getBlockHeightTip(): Promise<number> { |   $getBlockHeightTip(): Promise<number> { | ||||||
|     return this.$queryWrapper<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height'); |     return this.failoverRouter.$get<number>('/blocks/tip/height'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getBlockHashTip(): Promise<string> { |   $getBlockHashTip(): Promise<string> { | ||||||
|     return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/blocks/tip/hash'); |     return this.failoverRouter.$get<string>('/blocks/tip/hash'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getTxIdsForBlock(hash: string): Promise<string[]> { |   $getTxIdsForBlock(hash: string): Promise<string[]> { | ||||||
|     return this.$queryWrapper<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids'); |     return this.failoverRouter.$get<string[]>('/block/' + hash + '/txids'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> { |   $getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|     return this.$queryWrapper<IEsploraApi.Transaction[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txs'); |     return this.failoverRouter.$get<IEsploraApi.Transaction[]>('/block/' + hash + '/txs'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getBlockHash(height: number): Promise<string> { |   $getBlockHash(height: number): Promise<string> { | ||||||
|     return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height); |     return this.failoverRouter.$get<string>('/block-height/' + height); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getBlockHeader(hash: string): Promise<string> { |   $getBlockHeader(hash: string): Promise<string> { | ||||||
|     return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header'); |     return this.failoverRouter.$get<string>('/block/' + hash + '/header'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getBlock(hash: string): Promise<IEsploraApi.Block> { |   $getBlock(hash: string): Promise<IEsploraApi.Block> { | ||||||
|     return this.$queryWrapper<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash); |     return this.failoverRouter.$get<IEsploraApi.Block>('/block/' + hash); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getRawBlock(hash: string): Promise<Buffer> { |   $getRawBlock(hash: string): Promise<Buffer> { | ||||||
|     return this.$queryWrapper<any>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", 'arraybuffer') |     return this.failoverRouter.$get<any>('/block/' + hash + '/raw', 'arraybuffer') | ||||||
|       .then((response) => { return Buffer.from(response.data); }); |       .then((response) => { return Buffer.from(response.data); }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -158,11 +283,11 @@ class ElectrsApi implements AbstractBitcoinApi { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> { |   $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> { | ||||||
|     return this.$queryWrapper<IEsploraApi.Outspend>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout); |     return this.failoverRouter.$get<IEsploraApi.Outspend>('/tx/' + txId + '/outspend/' + vout); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> { |   $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> { | ||||||
|     return this.$queryWrapper<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends'); |     return this.failoverRouter.$get<IEsploraApi.Outspend[]>('/tx/' + txId + '/outspends'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]> { |   async $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]> { | ||||||
| @ -173,6 +298,10 @@ class ElectrsApi implements AbstractBitcoinApi { | |||||||
|     } |     } | ||||||
|     return outspends; |     return outspends; | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   public startHealthChecks(): void { | ||||||
|  |     this.failoverRouter.startHealthChecks(); | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default ElectrsApi; | export default ElectrsApi; | ||||||
|  | |||||||
| @ -674,7 +674,11 @@ class Blocks { | |||||||
|           this.updateTimerProgress(timer, 'got previous block hash for initial difficulty adjustment'); |           this.updateTimerProgress(timer, 'got previous block hash for initial difficulty adjustment'); | ||||||
|           const previousPeriodBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(previousPeriodBlockHash); |           const previousPeriodBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(previousPeriodBlockHash); | ||||||
|           this.updateTimerProgress(timer, 'got previous block for initial difficulty adjustment'); |           this.updateTimerProgress(timer, 'got previous block for initial difficulty adjustment'); | ||||||
|           this.previousDifficultyRetarget = calcBitsDifference(previousPeriodBlock.bits, block.bits); |           if (['liquid', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) { | ||||||
|  |             this.previousDifficultyRetarget = NaN; | ||||||
|  |           } else { | ||||||
|  |             this.previousDifficultyRetarget = calcBitsDifference(previousPeriodBlock.bits, block.bits); | ||||||
|  |           } | ||||||
|           logger.debug(`Initial difficulty adjustment data set.`); |           logger.debug(`Initial difficulty adjustment data set.`); | ||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
| @ -783,20 +787,31 @@ class Blocks { | |||||||
| 
 | 
 | ||||||
|       if (block.height % 2016 === 0) { |       if (block.height % 2016 === 0) { | ||||||
|         if (Common.indexingEnabled()) { |         if (Common.indexingEnabled()) { | ||||||
|  |           let adjustment; | ||||||
|  |           if (['liquid', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) { | ||||||
|  |             adjustment = NaN; | ||||||
|  |           } else { | ||||||
|  |             adjustment = Math.round( | ||||||
|  |               // calcBitsDifference returns +- percentage, +100 returns to positive, /100 returns to ratio.
 | ||||||
|  |               // Instead of actually doing /100, just reduce the multiplier.
 | ||||||
|  |               (calcBitsDifference(this.currentBits, block.bits) + 100) * 10000 | ||||||
|  |             ) / 1000000; // Remove float point noise
 | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|           await DifficultyAdjustmentsRepository.$saveAdjustments({ |           await DifficultyAdjustmentsRepository.$saveAdjustments({ | ||||||
|             time: block.timestamp, |             time: block.timestamp, | ||||||
|             height: block.height, |             height: block.height, | ||||||
|             difficulty: block.difficulty, |             difficulty: block.difficulty, | ||||||
|             adjustment: Math.round( |             adjustment, | ||||||
|               // calcBitsDifference returns +- percentage, +100 returns to positive, /100 returns to ratio.
 |  | ||||||
|               // Instead of actually doing /100, just reduce the multiplier.
 |  | ||||||
|               (calcBitsDifference(this.currentBits, block.bits) + 100) * 10000 |  | ||||||
|             ) / 1000000, // Remove float point noise
 |  | ||||||
|           }); |           }); | ||||||
|           this.updateTimerProgress(timer, `saved difficulty adjustment for ${this.currentBlockHeight}`); |           this.updateTimerProgress(timer, `saved difficulty adjustment for ${this.currentBlockHeight}`); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         this.previousDifficultyRetarget = calcBitsDifference(this.currentBits, block.bits); |         if (['liquid', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) { | ||||||
|  |           this.previousDifficultyRetarget = NaN; | ||||||
|  |         } else { | ||||||
|  |           this.previousDifficultyRetarget = calcBitsDifference(this.currentBits, block.bits); | ||||||
|  |         } | ||||||
|         this.lastDifficultyAdjustmentTime = block.timestamp; |         this.lastDifficultyAdjustmentTime = block.timestamp; | ||||||
|         this.currentBits = block.bits; |         this.currentBits = block.bits; | ||||||
|       } |       } | ||||||
|  | |||||||
| @ -59,10 +59,12 @@ export class Common { | |||||||
|     return arr; |     return arr; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   static findRbfTransactions(added: MempoolTransactionExtended[], deleted: MempoolTransactionExtended[]): { [txid: string]: MempoolTransactionExtended[] } { |   static findRbfTransactions(added: MempoolTransactionExtended[], deleted: MempoolTransactionExtended[], forceScalable = false): { [txid: string]: MempoolTransactionExtended[] } { | ||||||
|     const matches: { [txid: string]: MempoolTransactionExtended[] } = {}; |     const matches: { [txid: string]: MempoolTransactionExtended[] } = {}; | ||||||
|     added | 
 | ||||||
|       .forEach((addedTx) => { |     // For small N, a naive nested loop is extremely fast, but it doesn't scale
 | ||||||
|  |     if (added.length < 1000 && deleted.length < 50 && !forceScalable) { | ||||||
|  |       added.forEach((addedTx) => { | ||||||
|         const foundMatches = deleted.filter((deletedTx) => { |         const foundMatches = deleted.filter((deletedTx) => { | ||||||
|           // The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
 |           // The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
 | ||||||
|           return addedTx.fee > deletedTx.fee |           return addedTx.fee > deletedTx.fee | ||||||
| @ -73,9 +75,40 @@ export class Common { | |||||||
|               addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout)); |               addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout)); | ||||||
|             }); |             }); | ||||||
|         if (foundMatches?.length) { |         if (foundMatches?.length) { | ||||||
|           matches[addedTx.txid] = foundMatches; |           matches[addedTx.txid] = [...new Set(foundMatches)]; | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
|  |     } else { | ||||||
|  |       // for large N, build a lookup table of prevouts we can check in ~constant time
 | ||||||
|  |       const deletedSpendMap: { [txid: string]: { [vout: number]: MempoolTransactionExtended } } = {}; | ||||||
|  |       for (const tx of deleted) { | ||||||
|  |         for (const vin of tx.vin) { | ||||||
|  |           if (!deletedSpendMap[vin.txid]) { | ||||||
|  |             deletedSpendMap[vin.txid] = {}; | ||||||
|  |           } | ||||||
|  |           deletedSpendMap[vin.txid][vin.vout] = tx; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       for (const addedTx of added) { | ||||||
|  |         const foundMatches = new Set<MempoolTransactionExtended>(); | ||||||
|  |         for (const vin of addedTx.vin) { | ||||||
|  |           const deletedTx = deletedSpendMap[vin.txid]?.[vin.vout]; | ||||||
|  |           if (deletedTx && deletedTx.txid !== addedTx.txid | ||||||
|  |               // The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
 | ||||||
|  |               && addedTx.fee > deletedTx.fee | ||||||
|  |               // The new transaction must pay more fee per kB than the replaced tx.
 | ||||||
|  |               && addedTx.adjustedFeePerVsize > deletedTx.adjustedFeePerVsize | ||||||
|  |           ) { | ||||||
|  |             foundMatches.add(deletedTx); | ||||||
|  |           } | ||||||
|  |           if (foundMatches.size) { | ||||||
|  |             matches[addedTx.txid] = [...foundMatches]; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return matches; |     return matches; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -32,13 +32,13 @@ export interface DifficultyAdjustment { | |||||||
| export function calcBitsDifference(oldBits: number, newBits: number): number { | export function calcBitsDifference(oldBits: number, newBits: number): number { | ||||||
|   // Must be
 |   // Must be
 | ||||||
|   // - integer
 |   // - integer
 | ||||||
|   // - highest exponent is 0x1f, so max value (as integer) is 0x1f0000ff
 |   // - highest exponent is 0x20, so max value (as integer) is 0x207fffff
 | ||||||
|   // - min value is 1 (exponent = 0)
 |   // - min value is 1 (exponent = 0)
 | ||||||
|   // - highest bit of the number-part is +- sign, it must not be 1
 |   // - highest bit of the number-part is +- sign, it must not be 1
 | ||||||
|   const verifyBits = (bits: number): void => { |   const verifyBits = (bits: number): void => { | ||||||
|     if ( |     if ( | ||||||
|       Math.floor(bits) !== bits || |       Math.floor(bits) !== bits || | ||||||
|       bits > 0x1f0000ff || |       bits > 0x207fffff || | ||||||
|       bits < 1 || |       bits < 1 || | ||||||
|       (bits & 0x00800000) !== 0 || |       (bits & 0x00800000) !== 0 || | ||||||
|       (bits & 0x007fffff) === 0 |       (bits & 0x007fffff) === 0 | ||||||
|  | |||||||
| @ -451,6 +451,7 @@ class MempoolBlocks { | |||||||
|   private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], accelerations, accelerationPool, saveResults): MempoolBlockWithTransactions[] { |   private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], accelerations, accelerationPool, saveResults): MempoolBlockWithTransactions[] { | ||||||
|     for (const [txid, rate] of rates) { |     for (const [txid, rate] of rates) { | ||||||
|       if (txid in mempool) { |       if (txid in mempool) { | ||||||
|  |         mempool[txid].cpfpDirty = (rate !== mempool[txid].effectiveFeePerVsize); | ||||||
|         mempool[txid].effectiveFeePerVsize = rate; |         mempool[txid].effectiveFeePerVsize = rate; | ||||||
|         mempool[txid].cpfpChecked = false; |         mempool[txid].cpfpChecked = false; | ||||||
|       } |       } | ||||||
| @ -494,6 +495,9 @@ class MempoolBlocks { | |||||||
|               } |               } | ||||||
|             } |             } | ||||||
|           }); |           }); | ||||||
|  |           if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) { | ||||||
|  |             mempoolTx.cpfpDirty = true; | ||||||
|  |           } | ||||||
|           Object.assign(mempoolTx, {ancestors, descendants, bestDescendant: null, cpfpChecked: true}); |           Object.assign(mempoolTx, {ancestors, descendants, bestDescendant: null, cpfpChecked: true}); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| @ -531,12 +535,21 @@ class MempoolBlocks { | |||||||
| 
 | 
 | ||||||
|           const acceleration = accelerations[txid]; |           const acceleration = accelerations[txid]; | ||||||
|           if (isAccelerated[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) { |           if (isAccelerated[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) { | ||||||
|  |             if (!mempoolTx.acceleration) { | ||||||
|  |               mempoolTx.cpfpDirty = true; | ||||||
|  |             } | ||||||
|             mempoolTx.acceleration = true; |             mempoolTx.acceleration = true; | ||||||
|             for (const ancestor of mempoolTx.ancestors || []) { |             for (const ancestor of mempoolTx.ancestors || []) { | ||||||
|  |               if (!mempool[ancestor.txid].acceleration) { | ||||||
|  |                 mempool[ancestor.txid].cpfpDirty = true; | ||||||
|  |               } | ||||||
|               mempool[ancestor.txid].acceleration = true; |               mempool[ancestor.txid].acceleration = true; | ||||||
|               isAccelerated[ancestor.txid] = true; |               isAccelerated[ancestor.txid] = true; | ||||||
|             } |             } | ||||||
|           } else { |           } else { | ||||||
|  |             if (mempoolTx.acceleration) { | ||||||
|  |               mempoolTx.cpfpDirty = true; | ||||||
|  |             } | ||||||
|             delete mempoolTx.acceleration; |             delete mempoolTx.acceleration; | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ import PricesRepository from '../../repositories/PricesRepository'; | |||||||
| class MiningRoutes { | class MiningRoutes { | ||||||
|   public initRoutes(app: Application) { |   public initRoutes(app: Application) { | ||||||
|     app |     app | ||||||
|  |       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools', this.$listPools) | ||||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/:interval', this.$getPools) |       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/:interval', this.$getPools) | ||||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/hashrate', this.$getPoolHistoricalHashrate) |       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/hashrate', this.$getPoolHistoricalHashrate) | ||||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/blocks', this.$getPoolBlocks) |       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/blocks', this.$getPoolBlocks) | ||||||
| @ -41,6 +42,10 @@ class MiningRoutes { | |||||||
|       res.header('Pragma', 'public'); |       res.header('Pragma', 'public'); | ||||||
|       res.header('Cache-control', 'public'); |       res.header('Cache-control', 'public'); | ||||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); |       res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); | ||||||
|  |       if (['testnet', 'signet', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) { | ||||||
|  |         res.status(400).send('Prices are not available on testnets.'); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|       if (req.query.timestamp) { |       if (req.query.timestamp) { | ||||||
|         res.status(200).send(await PricesRepository.$getNearestHistoricalPrice( |         res.status(200).send(await PricesRepository.$getNearestHistoricalPrice( | ||||||
|           parseInt(<string>req.query.timestamp ?? 0, 10) |           parseInt(<string>req.query.timestamp ?? 0, 10) | ||||||
| @ -88,6 +93,29 @@ class MiningRoutes { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   private async $listPools(req: Request, res: Response): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       res.header('Pragma', 'public'); | ||||||
|  |       res.header('Cache-control', 'public'); | ||||||
|  |       res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||||
|  | 
 | ||||||
|  |       const pools = await mining.$listPools(); | ||||||
|  |       if (!pools) { | ||||||
|  |         res.status(500).end(); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       res.header('X-total-count', pools.length.toString()); | ||||||
|  |       if (pools.length === 0) { | ||||||
|  |         res.status(204).send(); | ||||||
|  |       } else { | ||||||
|  |         res.json(pools); | ||||||
|  |       } | ||||||
|  |     } catch (e) { | ||||||
|  |       res.status(500).send(e instanceof Error ? e.message : e); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   private async $getPools(req: Request, res: Response) { |   private async $getPools(req: Request, res: Response) { | ||||||
|     try { |     try { | ||||||
|       const stats = await mining.$getPoolsStats(req.params.interval); |       const stats = await mining.$getPoolsStats(req.params.interval); | ||||||
|  | |||||||
| @ -26,7 +26,7 @@ class Mining { | |||||||
|   /** |   /** | ||||||
|    * Get historical blocks health |    * Get historical blocks health | ||||||
|    */ |    */ | ||||||
|    public async $getBlocksHealthHistory(interval: string | null = null): Promise<any> { |   public async $getBlocksHealthHistory(interval: string | null = null): Promise<any> { | ||||||
|     return await BlocksAuditsRepository.$getBlocksHealthHistory( |     return await BlocksAuditsRepository.$getBlocksHealthHistory( | ||||||
|       this.getTimeRange(interval), |       this.getTimeRange(interval), | ||||||
|       Common.getSqlInterval(interval) |       Common.getSqlInterval(interval) | ||||||
| @ -56,7 +56,7 @@ class Mining { | |||||||
|   /** |   /** | ||||||
|    * Get historical block fee rates percentiles |    * Get historical block fee rates percentiles | ||||||
|    */ |    */ | ||||||
|    public async $getHistoricalBlockFeeRates(interval: string | null = null): Promise<any> { |   public async $getHistoricalBlockFeeRates(interval: string | null = null): Promise<any> { | ||||||
|     return await BlocksRepository.$getHistoricalBlockFeeRates( |     return await BlocksRepository.$getHistoricalBlockFeeRates( | ||||||
|       this.getTimeRange(interval), |       this.getTimeRange(interval), | ||||||
|       Common.getSqlInterval(interval) |       Common.getSqlInterval(interval) | ||||||
| @ -66,7 +66,7 @@ class Mining { | |||||||
|   /** |   /** | ||||||
|    * Get historical block sizes |    * Get historical block sizes | ||||||
|    */ |    */ | ||||||
|    public async $getHistoricalBlockSizes(interval: string | null = null): Promise<any> { |   public async $getHistoricalBlockSizes(interval: string | null = null): Promise<any> { | ||||||
|     return await BlocksRepository.$getHistoricalBlockSizes( |     return await BlocksRepository.$getHistoricalBlockSizes( | ||||||
|       this.getTimeRange(interval), |       this.getTimeRange(interval), | ||||||
|       Common.getSqlInterval(interval) |       Common.getSqlInterval(interval) | ||||||
| @ -76,7 +76,7 @@ class Mining { | |||||||
|   /** |   /** | ||||||
|    * Get historical block weights |    * Get historical block weights | ||||||
|    */ |    */ | ||||||
|    public async $getHistoricalBlockWeights(interval: string | null = null): Promise<any> { |   public async $getHistoricalBlockWeights(interval: string | null = null): Promise<any> { | ||||||
|     return await BlocksRepository.$getHistoricalBlockWeights( |     return await BlocksRepository.$getHistoricalBlockWeights( | ||||||
|       this.getTimeRange(interval), |       this.getTimeRange(interval), | ||||||
|       Common.getSqlInterval(interval) |       Common.getSqlInterval(interval) | ||||||
| @ -595,6 +595,20 @@ class Mining { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * List existing mining pools | ||||||
|  |    */ | ||||||
|  |   public async $listPools(): Promise<{name: string, slug: string, unique_id: number}[] | null> { | ||||||
|  |     const [rows] = await database.query(` | ||||||
|  |       SELECT | ||||||
|  |         name, | ||||||
|  |         slug, | ||||||
|  |         unique_id | ||||||
|  |       FROM pools` | ||||||
|  |     ); | ||||||
|  |     return rows as {name: string, slug: string, unique_id: number}[]; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   private getDateMidnight(date: Date): Date { |   private getDateMidnight(date: Date): Date { | ||||||
|     date.setUTCHours(0); |     date.setUTCHours(0); | ||||||
|     date.setUTCMinutes(0); |     date.setUTCMinutes(0); | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; | |||||||
| import * as bitcoinjs from 'bitcoinjs-lib'; | import * as bitcoinjs from 'bitcoinjs-lib'; | ||||||
| import logger from '../logger'; | import logger from '../logger'; | ||||||
| import config from '../config'; | import config from '../config'; | ||||||
|  | import pLimit from '../utils/p-limit'; | ||||||
| 
 | 
 | ||||||
| class TransactionUtils { | class TransactionUtils { | ||||||
|   constructor() { } |   constructor() { } | ||||||
| @ -74,8 +75,12 @@ class TransactionUtils { | |||||||
| 
 | 
 | ||||||
|   public async $getMempoolTransactionsExtended(txids: string[], addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<MempoolTransactionExtended[]> { |   public async $getMempoolTransactionsExtended(txids: string[], addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<MempoolTransactionExtended[]> { | ||||||
|     if (forceCore || config.MEMPOOL.BACKEND !== 'esplora') { |     if (forceCore || config.MEMPOOL.BACKEND !== 'esplora') { | ||||||
|       const results = await Promise.allSettled(txids.map(txid => this.$getTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore, true))); |       const limiter = pLimit(8); // Run 8 requests at a time
 | ||||||
|       return (results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<MempoolTransactionExtended>[]).map(r => r.value); |       const results = await Promise.allSettled(txids.map( | ||||||
|  |         txid => limiter(() => this.$getMempoolTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore)) | ||||||
|  |       )); | ||||||
|  |       return results.filter(reply => reply.status === 'fulfilled') | ||||||
|  |         .map(r => (r as PromiseFulfilledResult<MempoolTransactionExtended>).value); | ||||||
|     } else { |     } else { | ||||||
|       const transactions = await bitcoinApi.$getMempoolTransactions(txids); |       const transactions = await bitcoinApi.$getMempoolTransactions(txids); | ||||||
|       return transactions.map(transaction => { |       return transactions.map(transaction => { | ||||||
|  | |||||||
| @ -198,18 +198,14 @@ class WebsocketHandler { | |||||||
|                 matchedAddress = matchedAddress.toLowerCase(); |                 matchedAddress = matchedAddress.toLowerCase(); | ||||||
|               } |               } | ||||||
|               if (/^04[a-fA-F0-9]{128}$/.test(parsedMessage['track-address'])) { |               if (/^04[a-fA-F0-9]{128}$/.test(parsedMessage['track-address'])) { | ||||||
|                 client['track-address'] = null; |                 client['track-address'] = '41' + matchedAddress + 'ac'; | ||||||
|                 client['track-scriptpubkey'] = '41' + matchedAddress + 'ac'; |               } else if (/^(02|03)[a-fA-F0-9]{64}$/.test(parsedMessage['track-address'])) { | ||||||
|               } else if (/^|(02|03)[a-fA-F0-9]{64}$/.test(parsedMessage['track-address'])) { |                 client['track-address'] = '21' + matchedAddress + 'ac'; | ||||||
|                 client['track-address'] = null; |  | ||||||
|                 client['track-scriptpubkey'] = '21' + matchedAddress + 'ac'; |  | ||||||
|               } else { |               } else { | ||||||
|                 client['track-address'] = matchedAddress; |                 client['track-address'] = matchedAddress; | ||||||
|                 client['track-scriptpubkey'] = null; |  | ||||||
|               } |               } | ||||||
|             } else { |             } else { | ||||||
|               client['track-address'] = null; |               client['track-address'] = null; | ||||||
|               client['track-scriptpubkey'] = null; |  | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
| @ -488,6 +484,9 @@ class WebsocketHandler { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     // pre-compute address transactions
 | ||||||
|  |     const addressCache = this.makeAddressCache(newTransactions); | ||||||
|  | 
 | ||||||
|     this.wss.clients.forEach(async (client) => { |     this.wss.clients.forEach(async (client) => { | ||||||
|       if (client.readyState !== WebSocket.OPEN) { |       if (client.readyState !== WebSocket.OPEN) { | ||||||
|         return; |         return; | ||||||
| @ -527,78 +526,13 @@ class WebsocketHandler { | |||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (client['track-address']) { |       if (client['track-address']) { | ||||||
|         const foundTransactions: TransactionExtended[] = []; |         const foundTransactions = Array.from(addressCache[client['track-address']]?.values() || []); | ||||||
|  |         // txs may be missing prevouts in non-esplora backends
 | ||||||
|  |         // so fetch the full transactions now
 | ||||||
|  |         const fullTransactions = (config.MEMPOOL.BACKEND !== 'esplora') ? await this.getFullTransactions(foundTransactions) : foundTransactions; | ||||||
| 
 | 
 | ||||||
|         for (const tx of newTransactions) { |         if (fullTransactions.length) { | ||||||
|           const someVin = tx.vin.some((vin) => !!vin.prevout && vin.prevout.scriptpubkey_address === client['track-address']); |           response['address-transactions'] = JSON.stringify(fullTransactions); | ||||||
|           if (someVin) { |  | ||||||
|             if (config.MEMPOOL.BACKEND !== 'esplora') { |  | ||||||
|               try { |  | ||||||
|                 const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true); |  | ||||||
|                 foundTransactions.push(fullTx); |  | ||||||
|               } catch (e) { |  | ||||||
|                 logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); |  | ||||||
|               } |  | ||||||
|             } else { |  | ||||||
|               foundTransactions.push(tx); |  | ||||||
|             } |  | ||||||
|             return; |  | ||||||
|           } |  | ||||||
|           const someVout = tx.vout.some((vout) => vout.scriptpubkey_address === client['track-address']); |  | ||||||
|           if (someVout) { |  | ||||||
|             if (config.MEMPOOL.BACKEND !== 'esplora') { |  | ||||||
|               try { |  | ||||||
|                 const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true); |  | ||||||
|                 foundTransactions.push(fullTx); |  | ||||||
|               } catch (e) { |  | ||||||
|                 logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); |  | ||||||
|               } |  | ||||||
|             } else { |  | ||||||
|               foundTransactions.push(tx); |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (foundTransactions.length) { |  | ||||||
|           response['address-transactions'] = JSON.stringify(foundTransactions); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       if (client['track-scriptpubkey']) { |  | ||||||
|         const foundTransactions: TransactionExtended[] = []; |  | ||||||
| 
 |  | ||||||
|         for (const tx of newTransactions) { |  | ||||||
|           const someVin = tx.vin.some((vin) => !!vin.prevout && vin.prevout.scriptpubkey_type === 'p2pk' && vin.prevout.scriptpubkey === client['track-scriptpubkey']); |  | ||||||
|           if (someVin) { |  | ||||||
|             if (config.MEMPOOL.BACKEND !== 'esplora') { |  | ||||||
|               try { |  | ||||||
|                 const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true); |  | ||||||
|                 foundTransactions.push(fullTx); |  | ||||||
|               } catch (e) { |  | ||||||
|                 logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); |  | ||||||
|               } |  | ||||||
|             } else { |  | ||||||
|               foundTransactions.push(tx); |  | ||||||
|             } |  | ||||||
|             return; |  | ||||||
|           } |  | ||||||
|           const someVout = tx.vout.some((vout) => vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey === client['track-scriptpubkey']); |  | ||||||
|           if (someVout) { |  | ||||||
|             if (config.MEMPOOL.BACKEND !== 'esplora') { |  | ||||||
|               try { |  | ||||||
|                 const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true); |  | ||||||
|                 foundTransactions.push(fullTx); |  | ||||||
|               } catch (e) { |  | ||||||
|                 logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); |  | ||||||
|               } |  | ||||||
|             } else { |  | ||||||
|               foundTransactions.push(tx); |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (foundTransactions.length) { |  | ||||||
|           response['address-transactions'] = JSON.stringify(foundTransactions); |  | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
| @ -606,7 +540,6 @@ class WebsocketHandler { | |||||||
|         const foundTransactions: TransactionExtended[] = []; |         const foundTransactions: TransactionExtended[] = []; | ||||||
| 
 | 
 | ||||||
|         newTransactions.forEach((tx) => { |         newTransactions.forEach((tx) => { | ||||||
| 
 |  | ||||||
|           if (client['track-asset'] === Common.nativeAssetId) { |           if (client['track-asset'] === Common.nativeAssetId) { | ||||||
|             if (tx.vin.some((vin) => !!vin.is_pegin)) { |             if (tx.vin.some((vin) => !!vin.is_pegin)) { | ||||||
|               foundTransactions.push(tx); |               foundTransactions.push(tx); | ||||||
| @ -653,13 +586,25 @@ class WebsocketHandler { | |||||||
| 
 | 
 | ||||||
|         const mempoolTx = newMempool[trackTxid]; |         const mempoolTx = newMempool[trackTxid]; | ||||||
|         if (mempoolTx && mempoolTx.position) { |         if (mempoolTx && mempoolTx.position) { | ||||||
|           response['txPosition'] = JSON.stringify({ |           const positionData = { | ||||||
|             txid: trackTxid, |             txid: trackTxid, | ||||||
|             position: { |             position: { | ||||||
|               ...mempoolTx.position, |               ...mempoolTx.position, | ||||||
|               accelerated: mempoolTx.acceleration || undefined, |               accelerated: mempoolTx.acceleration || undefined, | ||||||
|             } |             } | ||||||
|           }); |           }; | ||||||
|  |           if (mempoolTx.cpfpDirty) { | ||||||
|  |             positionData['cpfp'] = { | ||||||
|  |               ancestors: mempoolTx.ancestors, | ||||||
|  |               bestDescendant: mempoolTx.bestDescendant || null, | ||||||
|  |               descendants: mempoolTx.descendants || null, | ||||||
|  |               effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize || null, | ||||||
|  |               sigops: mempoolTx.sigops, | ||||||
|  |               adjustedVsize: mempoolTx.adjustedVsize, | ||||||
|  |               acceleration: mempoolTx.acceleration | ||||||
|  |             }; | ||||||
|  |           } | ||||||
|  |           response['txPosition'] = JSON.stringify(positionData); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
| @ -805,6 +750,9 @@ class WebsocketHandler { | |||||||
|     const fees = feeApi.getRecommendedFee(); |     const fees = feeApi.getRecommendedFee(); | ||||||
|     const mempoolInfo = memPool.getMempoolInfo(); |     const mempoolInfo = memPool.getMempoolInfo(); | ||||||
| 
 | 
 | ||||||
|  |     // pre-compute address transactions
 | ||||||
|  |     const addressCache = this.makeAddressCache(transactions); | ||||||
|  | 
 | ||||||
|     // update init data
 |     // update init data
 | ||||||
|     this.updateSocketDataFields({ |     this.updateSocketDataFields({ | ||||||
|       'mempoolInfo': mempoolInfo, |       'mempoolInfo': mempoolInfo, | ||||||
| @ -867,44 +815,7 @@ class WebsocketHandler { | |||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (client['track-address']) { |       if (client['track-address']) { | ||||||
|         const foundTransactions: TransactionExtended[] = []; |         const foundTransactions: TransactionExtended[] = Array.from(addressCache[client['track-address']]?.values() || []); | ||||||
| 
 |  | ||||||
|         transactions.forEach((tx) => { |  | ||||||
|           if (tx.vin && tx.vin.some((vin) => !!vin.prevout && vin.prevout.scriptpubkey_address === client['track-address'])) { |  | ||||||
|             foundTransactions.push(tx); |  | ||||||
|             return; |  | ||||||
|           } |  | ||||||
|           if (tx.vout && tx.vout.some((vout) => vout.scriptpubkey_address === client['track-address'])) { |  | ||||||
|             foundTransactions.push(tx); |  | ||||||
|           } |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         if (foundTransactions.length) { |  | ||||||
|           foundTransactions.forEach((tx) => { |  | ||||||
|             tx.status = { |  | ||||||
|               confirmed: true, |  | ||||||
|               block_height: block.height, |  | ||||||
|               block_hash: block.id, |  | ||||||
|               block_time: block.timestamp, |  | ||||||
|             }; |  | ||||||
|           }); |  | ||||||
| 
 |  | ||||||
|           response['block-transactions'] = JSON.stringify(foundTransactions); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       if (client['track-scriptpubkey']) { |  | ||||||
|         const foundTransactions: TransactionExtended[] = []; |  | ||||||
| 
 |  | ||||||
|         transactions.forEach((tx) => { |  | ||||||
|           if (tx.vin && tx.vin.some((vin) => !!vin.prevout && vin.prevout.scriptpubkey_type === 'p2pk' && vin.prevout.scriptpubkey === client['track-scriptpubkey'])) { |  | ||||||
|             foundTransactions.push(tx); |  | ||||||
|             return; |  | ||||||
|           } |  | ||||||
|           if (tx.vout && tx.vout.some((vout) => vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey === client['track-scriptpubkey'])) { |  | ||||||
|             foundTransactions.push(tx); |  | ||||||
|           } |  | ||||||
|         }); |  | ||||||
| 
 | 
 | ||||||
|         if (foundTransactions.length) { |         if (foundTransactions.length) { | ||||||
|           foundTransactions.forEach((tx) => { |           foundTransactions.forEach((tx) => { | ||||||
| @ -982,6 +893,52 @@ class WebsocketHandler { | |||||||
|         + '}'; |         + '}'; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   private makeAddressCache(transactions: MempoolTransactionExtended[]): { [address: string]: Set<MempoolTransactionExtended> } { | ||||||
|  |     const addressCache: { [address: string]: Set<MempoolTransactionExtended> } = {}; | ||||||
|  |     for (const tx of transactions) { | ||||||
|  |       for (const vin of tx.vin) { | ||||||
|  |         if (vin?.prevout?.scriptpubkey_address) { | ||||||
|  |           if (!addressCache[vin.prevout.scriptpubkey_address]) { | ||||||
|  |             addressCache[vin.prevout.scriptpubkey_address] = new Set(); | ||||||
|  |           } | ||||||
|  |           addressCache[vin.prevout.scriptpubkey_address].add(tx); | ||||||
|  |         } | ||||||
|  |         if (vin?.prevout?.scriptpubkey) { | ||||||
|  |           if (!addressCache[vin.prevout.scriptpubkey]) { | ||||||
|  |             addressCache[vin.prevout.scriptpubkey] = new Set(); | ||||||
|  |           } | ||||||
|  |           addressCache[vin.prevout.scriptpubkey].add(tx); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       for (const vout of tx.vout) { | ||||||
|  |         if (vout?.scriptpubkey_address) { | ||||||
|  |           if (!addressCache[vout?.scriptpubkey_address]) { | ||||||
|  |             addressCache[vout?.scriptpubkey_address] = new Set(); | ||||||
|  |           } | ||||||
|  |           addressCache[vout?.scriptpubkey_address].add(tx); | ||||||
|  |         } | ||||||
|  |         if (vout?.scriptpubkey) { | ||||||
|  |           if (!addressCache[vout.scriptpubkey]) { | ||||||
|  |             addressCache[vout.scriptpubkey] = new Set(); | ||||||
|  |           } | ||||||
|  |           addressCache[vout.scriptpubkey].add(tx); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return addressCache; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private async getFullTransactions(transactions: MempoolTransactionExtended[]): Promise<MempoolTransactionExtended[]> { | ||||||
|  |     for (let i = 0; i < transactions.length; i++) { | ||||||
|  |       try { | ||||||
|  |         transactions[i] = await transactionUtils.$getMempoolTransactionExtended(transactions[i].txid, true); | ||||||
|  |       } catch (e) { | ||||||
|  |         logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return transactions; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   private printLogs(): void { |   private printLogs(): void { | ||||||
|     if (this.wss) { |     if (this.wss) { | ||||||
|       const count = this.wss?.clients?.size || 0; |       const count = this.wss?.clients?.size || 0; | ||||||
|  | |||||||
| @ -44,6 +44,7 @@ interface IConfig { | |||||||
|     REST_API_URL: string; |     REST_API_URL: string; | ||||||
|     UNIX_SOCKET_PATH: string | void | null; |     UNIX_SOCKET_PATH: string | void | null; | ||||||
|     RETRY_UNIX_SOCKET_AFTER: number; |     RETRY_UNIX_SOCKET_AFTER: number; | ||||||
|  |     FALLBACK: string[]; | ||||||
|   }; |   }; | ||||||
|   LIGHTNING: { |   LIGHTNING: { | ||||||
|     ENABLED: boolean; |     ENABLED: boolean; | ||||||
| @ -188,6 +189,7 @@ const defaults: IConfig = { | |||||||
|     'REST_API_URL': 'http://127.0.0.1:3000', |     'REST_API_URL': 'http://127.0.0.1:3000', | ||||||
|     'UNIX_SOCKET_PATH': null, |     'UNIX_SOCKET_PATH': null, | ||||||
|     'RETRY_UNIX_SOCKET_AFTER': 30000, |     'RETRY_UNIX_SOCKET_AFTER': 30000, | ||||||
|  |     'FALLBACK': [], | ||||||
|   }, |   }, | ||||||
|   'ELECTRUM': { |   'ELECTRUM': { | ||||||
|     'HOST': '127.0.0.1', |     'HOST': '127.0.0.1', | ||||||
|  | |||||||
| @ -91,6 +91,10 @@ class Server { | |||||||
|   async startServer(worker = false): Promise<void> { |   async startServer(worker = false): Promise<void> { | ||||||
|     logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`); |     logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`); | ||||||
| 
 | 
 | ||||||
|  |     if (config.MEMPOOL.BACKEND === 'esplora') { | ||||||
|  |       bitcoinApi.startHealthChecks(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     if (config.DATABASE.ENABLED) { |     if (config.DATABASE.ENABLED) { | ||||||
|       await DB.checkDbConnection(); |       await DB.checkDbConnection(); | ||||||
|       try { |       try { | ||||||
|  | |||||||
| @ -104,6 +104,7 @@ export interface MempoolTransactionExtended extends TransactionExtended { | |||||||
|   adjustedFeePerVsize: number; |   adjustedFeePerVsize: number; | ||||||
|   inputs?: number[]; |   inputs?: number[]; | ||||||
|   lastBoosted?: number; |   lastBoosted?: number; | ||||||
|  |   cpfpDirty?: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface AuditTransaction { | export interface AuditTransaction { | ||||||
|  | |||||||
							
								
								
									
										179
									
								
								backend/src/utils/p-limit.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								backend/src/utils/p-limit.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,179 @@ | |||||||
|  | /* | ||||||
|  | MIT License | ||||||
|  | 
 | ||||||
|  | Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
 | ||||||
|  | 
 | ||||||
|  | Permission is hereby granted, free of charge, to any person obtaining a copy of this | ||||||
|  | software and associated documentation files (the "Software"), to deal in the Software | ||||||
|  | without restriction, including without limitation the rights to use, copy, modify, | ||||||
|  | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to | ||||||
|  | permit persons to whom the Software is furnished to do so, subject to the following | ||||||
|  | conditions: | ||||||
|  | 
 | ||||||
|  | The above copyright notice and this permission notice shall be included in all copies | ||||||
|  | or substantial portions of the Software. | ||||||
|  | 
 | ||||||
|  | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | ||||||
|  | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | ||||||
|  | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | ||||||
|  | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | ||||||
|  | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | ||||||
|  | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||||||
|  | */ | ||||||
|  | 
 | ||||||
|  | /* | ||||||
|  | How it works: | ||||||
|  | `this._head` is an instance of `Node` which keeps track of its current value and nests | ||||||
|  | another instance of `Node` that keeps the value that comes after it. When a value is | ||||||
|  | provided to `.enqueue()`, the code needs to iterate through `this._head`, going deeper | ||||||
|  | and deeper to find the last value. However, iterating through every single item is slow. | ||||||
|  | This problem is solved by saving a reference to the last value as `this._tail` so that | ||||||
|  | it can reference it to add a new value. | ||||||
|  | */ | ||||||
|  | 
 | ||||||
|  | class Node { | ||||||
|  |   value; | ||||||
|  |   next; | ||||||
|  | 
 | ||||||
|  |   constructor(value) { | ||||||
|  |     this.value = value; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class Queue { | ||||||
|  |   private _head; | ||||||
|  |   private _tail; | ||||||
|  |   private _size; | ||||||
|  | 
 | ||||||
|  |   constructor() { | ||||||
|  |     this.clear(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   enqueue(value) { | ||||||
|  |     const node = new Node(value); | ||||||
|  | 
 | ||||||
|  |     if (this._head) { | ||||||
|  |       this._tail.next = node; | ||||||
|  |       this._tail = node; | ||||||
|  |     } else { | ||||||
|  |       this._head = node; | ||||||
|  |       this._tail = node; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this._size++; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   dequeue() { | ||||||
|  |     const current = this._head; | ||||||
|  |     if (!current) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this._head = this._head.next; | ||||||
|  |     this._size--; | ||||||
|  |     return current.value; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   clear() { | ||||||
|  |     this._head = undefined; | ||||||
|  |     this._tail = undefined; | ||||||
|  |     this._size = 0; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   get size() { | ||||||
|  |     return this._size; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   *[Symbol.iterator]() { | ||||||
|  |     let current = this._head; | ||||||
|  | 
 | ||||||
|  |     while (current) { | ||||||
|  |       yield current.value; | ||||||
|  |       current = current.next; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface LimitFunction { | ||||||
|  |   readonly activeCount: number; | ||||||
|  |   readonly pendingCount: number; | ||||||
|  |   clearQueue: () => void; | ||||||
|  |   <Arguments extends unknown[], ReturnType>( | ||||||
|  |     fn: (...args: Arguments) => PromiseLike<ReturnType> | ReturnType, | ||||||
|  |     ...args: Arguments | ||||||
|  |   ): Promise<ReturnType>; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default function pLimit(concurrency: number): LimitFunction { | ||||||
|  |   if ( | ||||||
|  |     !( | ||||||
|  |       (Number.isInteger(concurrency) || | ||||||
|  |         concurrency === Number.POSITIVE_INFINITY) && | ||||||
|  |       concurrency > 0 | ||||||
|  |     ) | ||||||
|  |   ) { | ||||||
|  |     throw new TypeError('Expected `concurrency` to be a number from 1 and up'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const queue = new Queue(); | ||||||
|  |   let activeCount = 0; | ||||||
|  | 
 | ||||||
|  |   const next = () => { | ||||||
|  |     activeCount--; | ||||||
|  | 
 | ||||||
|  |     if (queue.size > 0) { | ||||||
|  |       queue.dequeue()(); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const run = async (fn, resolve, args) => { | ||||||
|  |     activeCount++; | ||||||
|  | 
 | ||||||
|  |     const result = (async () => fn(...args))(); | ||||||
|  | 
 | ||||||
|  |     resolve(result); | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |       await result; | ||||||
|  |     } catch {} | ||||||
|  | 
 | ||||||
|  |     next(); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const enqueue = (fn, resolve, args) => { | ||||||
|  |     queue.enqueue(run.bind(undefined, fn, resolve, args)); | ||||||
|  | 
 | ||||||
|  |     (async () => { | ||||||
|  |       // This function needs to wait until the next microtask before comparing
 | ||||||
|  |       // `activeCount` to `concurrency`, because `activeCount` is updated asynchronously
 | ||||||
|  |       // when the run function is dequeued and called. The comparison in the if-statement
 | ||||||
|  |       // needs to happen asynchronously as well to get an up-to-date value for `activeCount`.
 | ||||||
|  |       await Promise.resolve(); | ||||||
|  | 
 | ||||||
|  |       if (activeCount < concurrency && queue.size > 0) { | ||||||
|  |         queue.dequeue()(); | ||||||
|  |       } | ||||||
|  |     })(); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const generator = (fn, ...args) => | ||||||
|  |     new Promise((resolve) => { | ||||||
|  |       enqueue(fn, resolve, args); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |   Object.defineProperties(generator, { | ||||||
|  |     activeCount: { | ||||||
|  |       get: () => activeCount, | ||||||
|  |     }, | ||||||
|  |     pendingCount: { | ||||||
|  |       get: () => queue.size, | ||||||
|  |     }, | ||||||
|  |     clearQueue: { | ||||||
|  |       value: () => { | ||||||
|  |         queue.clear(); | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return generator as any; | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								contributors/TheBlueMatt.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/TheBlueMatt.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file with sha256 hash c80c5ee4c71c5a76a1f6cd35339bd0c45b25b491933ea7b02a66470e9f43a6fd. | ||||||
|  | 
 | ||||||
|  | Signed: TheBlueMatt | ||||||
| @ -51,7 +51,8 @@ | |||||||
|   "ESPLORA": { |   "ESPLORA": { | ||||||
|     "REST_API_URL": "__ESPLORA_REST_API_URL__", |     "REST_API_URL": "__ESPLORA_REST_API_URL__", | ||||||
|     "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__", |     "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__", | ||||||
|     "RETRY_UNIX_SOCKET_AFTER": __ESPLORA_RETRY_UNIX_SOCKET_AFTER__ |     "RETRY_UNIX_SOCKET_AFTER": __ESPLORA_RETRY_UNIX_SOCKET_AFTER__, | ||||||
|  |     "FALLBACK": __ESPLORA_FALLBACK__ | ||||||
|   }, |   }, | ||||||
|   "SECOND_CORE_RPC": { |   "SECOND_CORE_RPC": { | ||||||
|     "HOST": "__SECOND_CORE_RPC_HOST__", |     "HOST": "__SECOND_CORE_RPC_HOST__", | ||||||
|  | |||||||
| @ -53,6 +53,7 @@ __ELECTRUM_TLS_ENABLED__=${ELECTRUM_TLS_ENABLED:=false} | |||||||
| __ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000} | __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:="null"} | ||||||
| __ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000} | __ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000} | ||||||
|  | __ESPLORA_FALLBACK__=${ESPLORA_FALLBACK:=[]} | ||||||
| 
 | 
 | ||||||
| # SECOND_CORE_RPC | # SECOND_CORE_RPC | ||||||
| __SECOND_CORE_RPC_HOST__=${SECOND_CORE_RPC_HOST:=127.0.0.1} | __SECOND_CORE_RPC_HOST__=${SECOND_CORE_RPC_HOST:=127.0.0.1} | ||||||
| @ -192,6 +193,7 @@ sed -i "s!__ELECTRUM_TLS_ENABLED__!${__ELECTRUM_TLS_ENABLED__}!g" mempool-config | |||||||
| sed -i "s!__ESPLORA_REST_API_URL__!${__ESPLORA_REST_API_URL__}!g" mempool-config.json | sed -i "s!__ESPLORA_REST_API_URL__!${__ESPLORA_REST_API_URL__}!g" mempool-config.json | ||||||
| sed -i "s!__ESPLORA_UNIX_SOCKET_PATH__!${__ESPLORA_UNIX_SOCKET_PATH__}!g" mempool-config.json | sed -i "s!__ESPLORA_UNIX_SOCKET_PATH__!${__ESPLORA_UNIX_SOCKET_PATH__}!g" mempool-config.json | ||||||
| sed -i "s!__ESPLORA_RETRY_UNIX_SOCKET_AFTER__!${__ESPLORA_RETRY_UNIX_SOCKET_AFTER__}!g" mempool-config.json | sed -i "s!__ESPLORA_RETRY_UNIX_SOCKET_AFTER__!${__ESPLORA_RETRY_UNIX_SOCKET_AFTER__}!g" mempool-config.json | ||||||
|  | sed -i "s!__ESPLORA_FALLBACK__!${__ESPLORA_FALLBACK__}!g" mempool-config.json | ||||||
| 
 | 
 | ||||||
| sed -i "s!__SECOND_CORE_RPC_HOST__!${__SECOND_CORE_RPC_HOST__}!g" mempool-config.json | sed -i "s!__SECOND_CORE_RPC_HOST__!${__SECOND_CORE_RPC_HOST__}!g" mempool-config.json | ||||||
| sed -i "s!__SECOND_CORE_RPC_PORT__!${__SECOND_CORE_RPC_PORT__}!g" mempool-config.json | sed -i "s!__SECOND_CORE_RPC_PORT__!${__SECOND_CORE_RPC_PORT__}!g" mempool-config.json | ||||||
|  | |||||||
| @ -42,9 +42,6 @@ | |||||||
| // -- This will overwrite an existing command --
 | // -- This will overwrite an existing command --
 | ||||||
| // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
 | ||||||
| 
 | 
 | ||||||
| 'use strict' |  | ||||||
| 
 |  | ||||||
| import 'cypress-wait-until'; |  | ||||||
| import { PageIdleDetector } from './PageIdleDetector'; | import { PageIdleDetector } from './PageIdleDetector'; | ||||||
| import { mockWebSocket } from './websocket'; | import { mockWebSocket } from './websocket'; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ | |||||||
| // ***********************************************************
 | // ***********************************************************
 | ||||||
| 
 | 
 | ||||||
| // When a command from ./commands is ready to use, import with `import './commands'` syntax
 | // When a command from ./commands is ready to use, import with `import './commands'` syntax
 | ||||||
|  | import 'cypress-wait-until'; | ||||||
| import './commands'; | import './commands'; | ||||||
| import failOnConsoleError from 'cypress-fail-on-console-error'; | import failOnConsoleError from 'cypress-fail-on-console-error'; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|   "extends": "../tsconfig.json", |   "extends": "../tsconfig.json", | ||||||
|   "include": ["**/*.ts"], |   "include": ["**/*.ts"], | ||||||
|   "compilerOptions": { |   "compilerOptions": { | ||||||
|     "types": ["cypress"], |     "types": ["cypress", "node", "cypress-wait-until"], | ||||||
|     "lib": ["es2015", "dom"], |     "lib": ["es2015", "dom"], | ||||||
|     "allowJs": true, |     "allowJs": true, | ||||||
|     "noEmit": true, |     "noEmit": true, | ||||||
|  | |||||||
							
								
								
									
										3429
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3429
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -61,18 +61,18 @@ | |||||||
|     "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" |     "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@angular-devkit/build-angular": "^16.1.4", |     "@angular-devkit/build-angular": "^16.2.0", | ||||||
|     "@angular/animations": "^16.1.5", |     "@angular/animations": "^16.2.2", | ||||||
|     "@angular/cli": "^16.1.4", |     "@angular/cli": "^16.2.0", | ||||||
|     "@angular/common": "^16.1.5", |     "@angular/common": "^16.2.2", | ||||||
|     "@angular/compiler": "^16.1.5", |     "@angular/compiler": "^16.2.2", | ||||||
|     "@angular/core": "^16.1.5", |     "@angular/core": "^16.2.2", | ||||||
|     "@angular/forms": "^16.1.5", |     "@angular/forms": "^16.2.2", | ||||||
|     "@angular/localize": "^16.1.5", |     "@angular/localize": "^16.2.2", | ||||||
|     "@angular/platform-browser": "^16.1.5", |     "@angular/platform-browser": "^16.2.2", | ||||||
|     "@angular/platform-browser-dynamic": "^16.1.5", |     "@angular/platform-browser-dynamic": "^16.2.2", | ||||||
|     "@angular/platform-server": "^16.1.5", |     "@angular/platform-server": "^16.2.2", | ||||||
|     "@angular/router": "^16.1.5", |     "@angular/router": "^16.2.2", | ||||||
|     "@fortawesome/angular-fontawesome": "~0.13.0", |     "@fortawesome/angular-fontawesome": "~0.13.0", | ||||||
|     "@fortawesome/fontawesome-common-types": "~6.4.0", |     "@fortawesome/fontawesome-common-types": "~6.4.0", | ||||||
|     "@fortawesome/fontawesome-svg-core": "~6.4.0", |     "@fortawesome/fontawesome-svg-core": "~6.4.0", | ||||||
| @ -110,9 +110,10 @@ | |||||||
|   }, |   }, | ||||||
|   "optionalDependencies": { |   "optionalDependencies": { | ||||||
|     "@cypress/schematic": "^2.5.0", |     "@cypress/schematic": "^2.5.0", | ||||||
|     "cypress": "^12.17.1", |     "@types/cypress": "^1.1.3", | ||||||
|  |     "cypress": "^12.17.2", | ||||||
|     "cypress-fail-on-console-error": "~4.0.3", |     "cypress-fail-on-console-error": "~4.0.3", | ||||||
|     "cypress-wait-until": "^1.7.2", |     "cypress-wait-until": "^2.0.0", | ||||||
|     "mock-socket": "~9.2.1", |     "mock-socket": "~9.2.1", | ||||||
|     "start-server-and-test": "~2.0.0" |     "start-server-and-test": "~2.0.0" | ||||||
|   }, |   }, | ||||||
|  | |||||||
| @ -112,6 +112,14 @@ PROXY_CONFIG.push(...[ | |||||||
|         "^/testnet": "" |         "^/testnet": "" | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|  |   { | ||||||
|  |     context: ['/api/v1/services/**'], | ||||||
|  |     target: `http://localhost:9000`, | ||||||
|  |     secure: false, | ||||||
|  |     ws: true, | ||||||
|  |     changeOrigin: true, | ||||||
|  |     proxyTimeout: 30000, | ||||||
|  |   }, | ||||||
|   { |   { | ||||||
|     context: ['/api/v1/**'], |     context: ['/api/v1/**'], | ||||||
|     target: `http://127.0.0.1:8999`, |     target: `http://127.0.0.1:8999`, | ||||||
|  | |||||||
| @ -112,6 +112,14 @@ PROXY_CONFIG.push(...[ | |||||||
|         "^/testnet": "" |         "^/testnet": "" | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|  |   { | ||||||
|  |     context: ['/api/v1/services/**'], | ||||||
|  |     target: `http://localhost:9000`, | ||||||
|  |     secure: false, | ||||||
|  |     ws: true, | ||||||
|  |     changeOrigin: true, | ||||||
|  |     proxyTimeout: 30000, | ||||||
|  |   }, | ||||||
|   { |   { | ||||||
|     context: ['/api/v1/**'], |     context: ['/api/v1/**'], | ||||||
|     target: `http://localhost:8999`, |     target: `http://localhost:8999`, | ||||||
|  | |||||||
| @ -95,6 +95,14 @@ if (configContent && configContent.BASE_MODULE === 'bisq') { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| PROXY_CONFIG.push(...[ | PROXY_CONFIG.push(...[ | ||||||
|  |   { | ||||||
|  |     context: ['/api/v1/services/**'], | ||||||
|  |     target: `http://localhost:9000`, | ||||||
|  |     secure: false, | ||||||
|  |     ws: true, | ||||||
|  |     changeOrigin: true, | ||||||
|  |     proxyTimeout: 30000, | ||||||
|  |   }, | ||||||
|   { |   { | ||||||
|     context: ['/api/v1/**'], |     context: ['/api/v1/**'], | ||||||
|     target: `http://localhost:8999`, |     target: `http://localhost:8999`, | ||||||
|  | |||||||
| @ -41,12 +41,14 @@ export class BisqAddressComponent implements OnInit, OnDestroy { | |||||||
|           document.body.scrollTo(0, 0); |           document.body.scrollTo(0, 0); | ||||||
|           this.addressString = params.get('id') || ''; |           this.addressString = params.get('id') || ''; | ||||||
|           this.seoService.setTitle($localize`:@@bisq-address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); |           this.seoService.setTitle($localize`:@@bisq-address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.bisq.address:See current balance, pending transactions, and history of confirmed transactions for BSQ address ${this.addressString}:INTERPOLATION:.`); | ||||||
| 
 | 
 | ||||||
|           return this.bisqApiService.getAddress$(this.addressString) |           return this.bisqApiService.getAddress$(this.addressString) | ||||||
|             .pipe( |             .pipe( | ||||||
|               catchError((err) => { |               catchError((err) => { | ||||||
|                 this.isLoadingAddress = false; |                 this.isLoadingAddress = false; | ||||||
|                 this.error = err; |                 this.error = err; | ||||||
|  |                 this.seoService.logSoft404(); | ||||||
|                 console.log(err); |                 console.log(err); | ||||||
|                 return of(null); |                 return of(null); | ||||||
|               }) |               }) | ||||||
| @ -62,6 +64,7 @@ export class BisqAddressComponent implements OnInit, OnDestroy { | |||||||
|       (error) => { |       (error) => { | ||||||
|         console.log(error); |         console.log(error); | ||||||
|         this.error = error; |         this.error = error; | ||||||
|  |         this.seoService.logSoft404(); | ||||||
|         this.isLoadingAddress = false; |         this.isLoadingAddress = false; | ||||||
|       }); |       }); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -82,11 +82,13 @@ export class BisqBlockComponent implements OnInit, OnDestroy { | |||||||
|       ) |       ) | ||||||
|       .subscribe((block: BisqBlock) => { |       .subscribe((block: BisqBlock) => { | ||||||
|         if (!block) { |         if (!block) { | ||||||
|  |           this.seoService.logSoft404(); | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
|         this.isLoading = false; |         this.isLoading = false; | ||||||
|         this.blockHeight = block.height; |         this.blockHeight = block.height; | ||||||
|         this.seoService.setTitle($localize`:@@bisq-block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.hash}:BLOCK_HASH:`); |         this.seoService.setTitle($localize`:@@bisq-block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.hash}:BLOCK_HASH:`); | ||||||
|  |         this.seoService.setDescription($localize`:@@meta.description.bisq.block:See all BSQ transactions in Bitcoin block ${block.height}:BLOCK_HEIGHT: (block hash ${block.hash}:BLOCK_HASH:).`); | ||||||
|         this.block = block; |         this.block = block; | ||||||
|       }); |       }); | ||||||
|   } |   } | ||||||
| @ -97,6 +99,7 @@ export class BisqBlockComponent implements OnInit, OnDestroy { | |||||||
| 
 | 
 | ||||||
|   caughtHttpError(err: HttpErrorResponse){ |   caughtHttpError(err: HttpErrorResponse){ | ||||||
|     this.error = err; |     this.error = err; | ||||||
|  |     this.seoService.logSoft404(); | ||||||
|     return of(null); |     return of(null); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -36,6 +36,7 @@ export class BisqBlocksComponent implements OnInit { | |||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.websocketService.want(['blocks']); |     this.websocketService.want(['blocks']); | ||||||
|     this.seoService.setTitle($localize`:@@8a7b4bd44c0ac71b2e72de0398b303257f7d2f54:Blocks`); |     this.seoService.setTitle($localize`:@@8a7b4bd44c0ac71b2e72de0398b303257f7d2f54:Blocks`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bisq.blocks:See a list of recent Bitcoin blocks with BSQ transactions, total BSQ sent per block, and more.`); | ||||||
|     this.itemsPerPage = Math.max(Math.round(this.contentSpace / this.fiveItemsPxSize) * 5, 10); |     this.itemsPerPage = Math.max(Math.round(this.contentSpace / this.fiveItemsPxSize) * 5, 10); | ||||||
|     this.loadingItems = Array(this.itemsPerPage); |     this.loadingItems = Array(this.itemsPerPage); | ||||||
|     if (document.body.clientWidth < 670) { |     if (document.body.clientWidth < 670) { | ||||||
|  | |||||||
| @ -29,7 +29,8 @@ export class BisqDashboardComponent implements OnInit { | |||||||
|   ) { } |   ) { } | ||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.seoService.setTitle(`Markets`); |     this.seoService.setTitle($localize`:@@meta.title.bisq.markets:Markets`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bisq.markets:Explore the full Bitcoin ecosystem with The Mempool Open Project™. See Bisq market prices, trading activity, and more.`); | ||||||
|     this.websocketService.want(['blocks']); |     this.websocketService.want(['blocks']); | ||||||
| 
 | 
 | ||||||
|     this.volumes$ = this.bisqApiService.getAllVolumesDay$() |     this.volumes$ = this.bisqApiService.getAllVolumesDay$() | ||||||
|  | |||||||
| @ -34,6 +34,7 @@ export class BisqMainDashboardComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.seoService.resetTitle(); |     this.seoService.resetTitle(); | ||||||
|  |     this.seoService.resetDescription(); | ||||||
|     this.websocketService.want(['blocks']); |     this.websocketService.want(['blocks']); | ||||||
| 
 | 
 | ||||||
|     this.usdPrice$ = this.stateService.conversions$.asObservable().pipe( |     this.usdPrice$ = this.stateService.conversions$.asObservable().pipe( | ||||||
|  | |||||||
| @ -48,7 +48,8 @@ export class BisqMarketComponent implements OnInit, OnDestroy { | |||||||
|         map(([markets, routeParams]) => { |         map(([markets, routeParams]) => { | ||||||
|           const pair = routeParams.get('pair'); |           const pair = routeParams.get('pair'); | ||||||
|           const pairUpperCase = pair.replace('_', '/').toUpperCase(); |           const pairUpperCase = pair.replace('_', '/').toUpperCase(); | ||||||
|           this.seoService.setTitle(`Bisq market: ${pairUpperCase}`); |           this.seoService.setTitle($localize`:@@meta.title.bisq.market:Bisq market: ${pairUpperCase}`); | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.bisq.market:See price history, current buy/sell offers, and latest trades for the ${pairUpperCase} market on Bisq.`); | ||||||
| 
 | 
 | ||||||
|           return { |           return { | ||||||
|             pair: pairUpperCase, |             pair: pairUpperCase, | ||||||
|  | |||||||
| @ -26,6 +26,7 @@ export class BisqStatsComponent implements OnInit { | |||||||
|     this.websocketService.want(['blocks']); |     this.websocketService.want(['blocks']); | ||||||
| 
 | 
 | ||||||
|     this.seoService.setTitle($localize`:@@2a30a4cdb123a03facc5ab8c5b3e6d8b8dbbc3d4:BSQ statistics`); |     this.seoService.setTitle($localize`:@@2a30a4cdb123a03facc5ab8c5b3e6d8b8dbbc3d4:BSQ statistics`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bisq.stats:See high-level stats on the BSQ economy: supply metrics, number of addresses, BSQ price, market cap, and more.`); | ||||||
|     this.stateService.bsqPrice$ |     this.stateService.bsqPrice$ | ||||||
|       .subscribe((bsqPrice) => { |       .subscribe((bsqPrice) => { | ||||||
|         this.price = bsqPrice; |         this.price = bsqPrice; | ||||||
|  | |||||||
| @ -48,6 +48,7 @@ export class BisqTransactionComponent implements OnInit, OnDestroy { | |||||||
|         document.body.scrollTo(0, 0); |         document.body.scrollTo(0, 0); | ||||||
|         this.txId = params.get('id') || ''; |         this.txId = params.get('id') || ''; | ||||||
|         this.seoService.setTitle($localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`); |         this.seoService.setTitle($localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`); | ||||||
|  |         this.seoService.setDescription($localize`:@@meta.description.bisq.transaction:See inputs, outputs, transaction type, burnt amount, and more for transaction with txid ${this.txId}:INTERPOLATION:.`); | ||||||
|         if (history.state.data) { |         if (history.state.data) { | ||||||
|           return of(history.state.data); |           return of(history.state.data); | ||||||
|         } |         } | ||||||
| @ -70,11 +71,13 @@ export class BisqTransactionComponent implements OnInit, OnDestroy { | |||||||
|                     catchError((txError: HttpErrorResponse) => { |                     catchError((txError: HttpErrorResponse) => { | ||||||
|                       console.log(txError); |                       console.log(txError); | ||||||
|                       this.error = txError; |                       this.error = txError; | ||||||
|  |                       this.seoService.logSoft404(); | ||||||
|                       return of(null); |                       return of(null); | ||||||
|                     }) |                     }) | ||||||
|                   ); |                   ); | ||||||
|               } |               } | ||||||
|               this.error = bisqTxError; |               this.error = bisqTxError; | ||||||
|  |               this.seoService.logSoft404(); | ||||||
|               return of(null); |               return of(null); | ||||||
|             }) |             }) | ||||||
|           ); |           ); | ||||||
| @ -103,6 +106,7 @@ export class BisqTransactionComponent implements OnInit, OnDestroy { | |||||||
|       this.isLoadingTx = false; |       this.isLoadingTx = false; | ||||||
| 
 | 
 | ||||||
|       if (!tx) { |       if (!tx) { | ||||||
|  |         this.seoService.logSoft404(); | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -79,6 +79,7 @@ export class BisqTransactionsComponent implements OnInit, OnDestroy { | |||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.websocketService.want(['blocks']); |     this.websocketService.want(['blocks']); | ||||||
|     this.seoService.setTitle($localize`:@@add4cd82e3e38a3110fe67b3c7df56e9602644ee:Transactions`); |     this.seoService.setTitle($localize`:@@add4cd82e3e38a3110fe67b3c7df56e9602644ee:Transactions`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bisq.transactions:See recent BSQ transactions: amount, txid, associated Bitcoin block, transaction type, and more.`); | ||||||
| 
 | 
 | ||||||
|     this.radioGroupForm = this.formBuilder.group({ |     this.radioGroupForm = this.formBuilder.group({ | ||||||
|       txTypes: [this.txTypesDefaultChecked], |       txTypes: [this.txTypesDefaultChecked], | ||||||
|  | |||||||
| @ -4,7 +4,8 @@ | |||||||
|     <span style="margin-left: auto; margin-right: -20px; margin-bottom: -20px">®</span> |     <span style="margin-left: auto; margin-right: -20px; margin-bottom: -20px">®</span> | ||||||
|     <img class="logo" src="/resources/mempool-logo-bigger.png" /> |     <img class="logo" src="/resources/mempool-logo-bigger.png" /> | ||||||
|     <div class="version"> |     <div class="version"> | ||||||
|       v{{ packetJsonVersion }} [<a href="https://github.com/mempool/mempool/commit/{{ frontendGitCommitHash }}">{{ frontendGitCommitHash }}</a>] |       <span>v{{ packetJsonVersion }} [<a href="https://github.com/mempool/mempool/commit/{{ frontendGitCommitHash }}">{{ frontendGitCommitHash }}</a>]</span> | ||||||
|  |       <span *ngIf="stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE">[{{ stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE }}]</span> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| 
 | 
 | ||||||
| @ -181,21 +182,6 @@ | |||||||
|         </svg> |         </svg> | ||||||
|         <span>Exodus</span> |         <span>Exodus</span> | ||||||
|       </a> |       </a> | ||||||
|       <a href="https://www.luminex.io" target="_blank" title="Luminex"> |  | ||||||
|         <svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="66.95" height="80" viewBox="0 0 300.43 385" style="padding-top: 10px;"> |  | ||||||
|           <defs> |  | ||||||
|             <style> |  | ||||||
|               .lum-cls-1 { |  | ||||||
|                 fill: #f2ea25; |  | ||||||
|               } |  | ||||||
|             </style> |  | ||||||
|           </defs> |  | ||||||
|           <path class="lum-cls-1" d="m309.02,90.04c0,49.65-38.73,90.04-95.34,90.04s-95.34-40.39-95.34-90.04S153.77,0,213.69,0c56.28,0,95.34,40.39,95.34,90.04Zm-63.56,0c0-20.52-14.23-37.07-31.78-37.07s-31.78,16.55-31.78,37.07,14.23,37.07,31.78,37.07,31.78-16.55,31.78-37.07Z"/> |  | ||||||
|           <path class="lum-cls-1" d="m311.87,372.67h-66.34l-31.84-47.76-31.84,47.76h-66.34l58.38-90.22-53.07-79.61h66.34l26.54,42.46,26.53-42.46h66.34l-53.07,79.61,58.38,90.22Z"/> |  | ||||||
|           <rect class="lum-cls-1" width="60.69" height="372.67"/> |  | ||||||
|         </svg> |  | ||||||
|         <span>Luminex</span> |  | ||||||
|       </a> |  | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| 
 | 
 | ||||||
| @ -257,7 +243,7 @@ | |||||||
|         <img class="image" src="/resources/profile/ronindojo.png" /> |         <img class="image" src="/resources/profile/ronindojo.png" /> | ||||||
|         <span>RoninDojo</span> |         <span>RoninDojo</span> | ||||||
|       </a> |       </a> | ||||||
|       <a href="https://github.com/runcitadel/core" target="_blank" title="Citadel"> |       <a href="https://github.com/runcitadel" target="_blank" title="Citadel"> | ||||||
|         <img class="image" src="/resources/profile/runcitadel.svg" /> |         <img class="image" src="/resources/profile/runcitadel.svg" /> | ||||||
|         <span>Citadel</span> |         <span>Citadel</span> | ||||||
|       </a> |       </a> | ||||||
|  | |||||||
| @ -22,6 +22,7 @@ | |||||||
| 
 | 
 | ||||||
|   .intro { |   .intro { | ||||||
|     margin: 25px auto 30px; |     margin: 25px auto 30px; | ||||||
|  |     margin-top: 25px; | ||||||
|     width: 250px; |     width: 250px; | ||||||
|     display: flex; |     display: flex; | ||||||
|     flex-direction: column; |     flex-direction: column; | ||||||
|  | |||||||
| @ -43,6 +43,7 @@ export class AboutComponent implements OnInit { | |||||||
|   ngOnInit() { |   ngOnInit() { | ||||||
|     this.backendInfo$ = this.stateService.backendInfo$; |     this.backendInfo$ = this.stateService.backendInfo$; | ||||||
|     this.seoService.setTitle($localize`:@@004b222ff9ef9dd4771b777950ca1d0e4cd4348a:About`); |     this.seoService.setTitle($localize`:@@004b222ff9ef9dd4771b777950ca1d0e4cd4348a:About`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.about:Learn more about The Mempool Open Source Project™\: enterprise sponsors, individual sponsors, integrations, who contributes, FOSS licensing, and more.`); | ||||||
|     this.websocketService.want(['blocks']); |     this.websocketService.want(['blocks']); | ||||||
| 
 | 
 | ||||||
|     this.profiles$ = this.apiService.getAboutPageProfiles$().pipe( |     this.profiles$ = this.apiService.getAboutPageProfiles$().pipe( | ||||||
|  | |||||||
| @ -0,0 +1,21 @@ | |||||||
|  | <div class="fee-graph" *ngIf="tx && estimate"> | ||||||
|  |   <div class="column"> | ||||||
|  |     <ng-container *ngFor="let bar of bars"> | ||||||
|  |       <div class="bar {{ bar.class }}" [class.active]="bar.active" [style]="bar.style" (click)="onClick($event, bar);"> | ||||||
|  |         <div class="fill"></div> | ||||||
|  |         <div class="line"> | ||||||
|  |           <p class="fee-rate"> | ||||||
|  |             <span class="label">{{ bar.label }}</span> | ||||||
|  |             <span class="rate"> | ||||||
|  |               <app-fee-rate [fee]="bar.rate"></app-fee-rate> | ||||||
|  |             </span> | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|  |         <div class="spacer"></div> | ||||||
|  |         <span class="fee">{{ bar.class === 'tx' ? '' : '+' }} {{ bar.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></span> | ||||||
|  |         <div class="spacer"></div> | ||||||
|  |         <div class="spacer"></div> | ||||||
|  |       </div> | ||||||
|  |     </ng-container> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @ -0,0 +1,157 @@ | |||||||
|  | .fee-graph { | ||||||
|  |   height: 100%; | ||||||
|  |   min-width: 120px; | ||||||
|  |   width: 120px; | ||||||
|  |   max-height: 90vh; | ||||||
|  |   margin-left: 4em; | ||||||
|  |   margin-right: 1.5em; | ||||||
|  |   padding-bottom: 63px; | ||||||
|  | 
 | ||||||
|  |   .column { | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     position: relative; | ||||||
|  |     background: #181b2d; | ||||||
|  | 
 | ||||||
|  |     .bar { | ||||||
|  |       position: absolute; | ||||||
|  |       bottom: 0; | ||||||
|  |       left: 0; | ||||||
|  |       right: 0; | ||||||
|  |       display: flex; | ||||||
|  |       flex-direction: column; | ||||||
|  |       justify-content: center; | ||||||
|  |       align-items: center; | ||||||
|  | 
 | ||||||
|  |       .fill { | ||||||
|  |         position: absolute; | ||||||
|  |         left: 0; | ||||||
|  |         right: 0; | ||||||
|  |         top: 0; | ||||||
|  |         bottom: 0; | ||||||
|  |         opacity: 0.75; | ||||||
|  |         pointer-events: none; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       .fee { | ||||||
|  |         font-size: 0.9em; | ||||||
|  |         opacity: 0; | ||||||
|  |         pointer-events: none; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       .spacer { | ||||||
|  |         width: 100%; | ||||||
|  |         height: 1px; | ||||||
|  |         flex-grow: 1; | ||||||
|  |         pointer-events: none; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       .line { | ||||||
|  |         position: absolute; | ||||||
|  |         right: 0; | ||||||
|  |         top: 0; | ||||||
|  |         left: -4.5em; | ||||||
|  |         border-top: dashed white 1.5px; | ||||||
|  | 
 | ||||||
|  |         .fee-rate { | ||||||
|  |           width: 100%; | ||||||
|  |           position: absolute; | ||||||
|  |           left: 0; | ||||||
|  |           right: 0.2em; | ||||||
|  |           font-size: 0.8em; | ||||||
|  |           display: flex; | ||||||
|  |           flex-direction: row-reverse; | ||||||
|  |           justify-content: space-between; | ||||||
|  |           margin: 0; | ||||||
|  | 
 | ||||||
|  |           .label { | ||||||
|  |             margin-right: .2em; | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|  |           .rate .symbol { | ||||||
|  |             color: white; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       &.tx { | ||||||
|  |         .fill { | ||||||
|  |           background: #3bcc49; | ||||||
|  |         } | ||||||
|  |         .line { | ||||||
|  |           .fee-rate { | ||||||
|  |             top: 0; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         .fee { | ||||||
|  |           position: absolute; | ||||||
|  |           opacity: 1; | ||||||
|  |           z-index: 11; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       &.target { | ||||||
|  |         .fill { | ||||||
|  |           background: #653b9c; | ||||||
|  |         } | ||||||
|  |         .fee { | ||||||
|  |           position: absolute; | ||||||
|  |           opacity: 1; | ||||||
|  |           z-index: 11; | ||||||
|  |         } | ||||||
|  |         .line .fee-rate { | ||||||
|  |           bottom: 2px; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       &.max { | ||||||
|  |         cursor: pointer; | ||||||
|  |         .line .fee-rate { | ||||||
|  |           .label { | ||||||
|  |             opacity: 0; | ||||||
|  |           } | ||||||
|  |           bottom: 2px; | ||||||
|  |         } | ||||||
|  |         &.active, &:hover { | ||||||
|  |           .fill { | ||||||
|  |             background: #105fb0; | ||||||
|  |           } | ||||||
|  |           .line { | ||||||
|  |             .fee-rate .label { | ||||||
|  |               opacity: 1; | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       &:hover { | ||||||
|  |         .fill { | ||||||
|  |           z-index: 10; | ||||||
|  |         } | ||||||
|  |         .line { | ||||||
|  |           z-index: 11; | ||||||
|  |         } | ||||||
|  |         .fee { | ||||||
|  |           opacity: 1; | ||||||
|  |           z-index: 12; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &:hover > .bar:not(:hover) { | ||||||
|  |       &.target, &.max { | ||||||
|  |         .fee { | ||||||
|  |           opacity: 0; | ||||||
|  |         } | ||||||
|  |         .line .fee-rate .label { | ||||||
|  |           opacity: 0; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       &.max { | ||||||
|  |         .fill { | ||||||
|  |           background: none; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -0,0 +1,96 @@ | |||||||
|  | import { Component, OnInit, Input, Output, OnChanges, EventEmitter, HostListener, Inject, LOCALE_ID } from '@angular/core'; | ||||||
|  | import { StateService } from '../../services/state.service'; | ||||||
|  | import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; | ||||||
|  | import { Router } from '@angular/router'; | ||||||
|  | import { ReplaySubject, merge, Subscription, of } from 'rxjs'; | ||||||
|  | import { tap, switchMap } from 'rxjs/operators'; | ||||||
|  | import { ApiService } from '../../services/api.service'; | ||||||
|  | import { AccelerationEstimate, RateOption } from './accelerate-preview.component'; | ||||||
|  | 
 | ||||||
|  | interface GraphBar { | ||||||
|  |   rate: number; | ||||||
|  |   style: any; | ||||||
|  |   class: 'tx' | 'target' | 'max'; | ||||||
|  |   label: string; | ||||||
|  |   active?: boolean; | ||||||
|  |   rateIndex?: number; | ||||||
|  |   fee?: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-accelerate-fee-graph', | ||||||
|  |   templateUrl: './accelerate-fee-graph.component.html', | ||||||
|  |   styleUrls: ['./accelerate-fee-graph.component.scss'], | ||||||
|  | }) | ||||||
|  | export class AccelerateFeeGraphComponent implements OnInit, OnChanges { | ||||||
|  |   @Input() tx: Transaction; | ||||||
|  |   @Input() estimate: AccelerationEstimate; | ||||||
|  |   @Input() maxRateOptions: RateOption[] = []; | ||||||
|  |   @Input() maxRateIndex: number = 0; | ||||||
|  |   @Output() setUserBid = new EventEmitter<{ fee: number, index: number }>(); | ||||||
|  | 
 | ||||||
|  |   bars: GraphBar[] = []; | ||||||
|  |   tooltipPosition = { x: 0, y: 0 }; | ||||||
|  | 
 | ||||||
|  |   ngOnInit(): void { | ||||||
|  |     this.initGraph(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   ngOnChanges(): void { | ||||||
|  |     this.initGraph(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   initGraph(): void { | ||||||
|  |     if (!this.tx || !this.estimate) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const maxRate = Math.max(...this.maxRateOptions.map(option => option.rate)); | ||||||
|  |     const baseRate = this.estimate.txSummary.effectiveFee / this.estimate.txSummary.effectiveVsize; | ||||||
|  |     const baseHeight = baseRate / maxRate; | ||||||
|  |     const bars: GraphBar[] = this.maxRateOptions.slice().reverse().map(option => { | ||||||
|  |       return { | ||||||
|  |         rate: option.rate, | ||||||
|  |         style: this.getStyle(option.rate, maxRate, baseHeight), | ||||||
|  |         class: 'max', | ||||||
|  |         label: 'maximum', | ||||||
|  |         active: option.index === this.maxRateIndex, | ||||||
|  |         rateIndex: option.index, | ||||||
|  |         fee: option.fee, | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     bars.push({ | ||||||
|  |       rate: this.estimate.targetFeeRate, | ||||||
|  |       style: this.getStyle(this.estimate.targetFeeRate, maxRate, baseHeight), | ||||||
|  |       class: 'target', | ||||||
|  |       label: 'next block', | ||||||
|  |       fee: this.estimate.nextBlockFee - this.estimate.txSummary.effectiveFee | ||||||
|  |     }); | ||||||
|  |     bars.push({ | ||||||
|  |       rate: baseRate, | ||||||
|  |       style: this.getStyle(baseRate, maxRate, 0), | ||||||
|  |       class: 'tx', | ||||||
|  |       label: '', | ||||||
|  |       fee: this.estimate.txSummary.effectiveFee, | ||||||
|  |     }); | ||||||
|  |     this.bars = bars; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   getStyle(rate, maxRate, base) { | ||||||
|  |     const top = (rate / maxRate); | ||||||
|  |     return { | ||||||
|  |       height: `${(top - base) * 100}%`, | ||||||
|  |       bottom: base ? `${base * 100}%` : '0', | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   onClick(event, bar): void { | ||||||
|  |     if (bar.rateIndex != null) { | ||||||
|  |       this.setUserBid.emit({ fee: bar.fee, index: bar.rateIndex }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @HostListener('pointermove', ['$event']) | ||||||
|  |   onPointerMove(event) { | ||||||
|  |     this.tooltipPosition = { x: event.offsetX, y: event.offsetY }; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -0,0 +1,262 @@ | |||||||
|  | <div class="row" *ngIf="showSuccess"> | ||||||
|  |   <div class="col" id="successAlert"> | ||||||
|  |     <div class="alert alert-success"> | ||||||
|  |       Transaction has now been submitted to mining pools for acceleration. You can track the progress <a class="alert-link" routerLink="/services/accelerator/history">here</a>. | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | 
 | ||||||
|  | <div class="row" *ngIf="error"> | ||||||
|  |   <div class="col" id="mempoolError"> | ||||||
|  |     <app-mempool-error [error]="error"></app-mempool-error> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | 
 | ||||||
|  | <div class="accelerate-cols"> | ||||||
|  |   <ng-container *ngIf="!isMobile"> | ||||||
|  |     <app-accelerate-fee-graph | ||||||
|  |       [tx]="tx" | ||||||
|  |       [estimate]="estimate" | ||||||
|  |       [maxRateOptions]="maxRateOptions" | ||||||
|  |       [maxRateIndex]="selectFeeRateIndex" | ||||||
|  |       (setUserBid)="setUserBid($event)" | ||||||
|  |     ></app-accelerate-fee-graph> | ||||||
|  |   </ng-container> | ||||||
|  | 
 | ||||||
|  |   <ng-container *ngIf="estimate"> | ||||||
|  |     <div [class]="{estimateDisabled: error}"> | ||||||
|  |       <h5>Your transaction</h5> | ||||||
|  |       <div class="row"> | ||||||
|  |         <div class="col"> | ||||||
|  |           <small *ngIf="hasAncestors" class="form-text text-muted mb-2"> | ||||||
|  |             Plus {{ estimate.txSummary.ancestorCount - 1 }} unconfirmed ancestor{{ estimate.txSummary.ancestorCount > 2 ? 's' : ''}}. | ||||||
|  |           </small> | ||||||
|  |           <table class="table table-borderless table-border table-dark table-accelerator"> | ||||||
|  |             <tbody> | ||||||
|  |               <tr class="group-first"> | ||||||
|  |                 <td class="item"> | ||||||
|  |                   Virtual size | ||||||
|  |                 </td> | ||||||
|  |                 <td class="units" [innerHTML]="'‎' + (estimate.txSummary.effectiveVsize | vbytes: 2)"></td> | ||||||
|  |               </tr> | ||||||
|  |               <tr class="info"> | ||||||
|  |                 <td class="info"> | ||||||
|  |                   <i><small>Size in vbytes of this transaction<span *ngIf="hasAncestors"> and its unconfirmed ancestors</span></small></i> | ||||||
|  |                 </td> | ||||||
|  |               </tr> | ||||||
|  |               <tr> | ||||||
|  |                 <td class="item"> | ||||||
|  |                   In-band fees | ||||||
|  |                 </td> | ||||||
|  |                 <td class="units"> | ||||||
|  |                   {{ estimate.txSummary.effectiveFee | number : '1.0-0' }} <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||||
|  |                 </td> | ||||||
|  |               </tr> | ||||||
|  |               <tr class="info group-last"> | ||||||
|  |                 <td class="info"> | ||||||
|  |                   <i><small>Fees already paid by this transaction<span *ngIf="hasAncestors"> and its unconfirmed ancestors</span></small></i> | ||||||
|  |                 </td> | ||||||
|  |               </tr> | ||||||
|  |             </tbody> | ||||||
|  |           </table> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <br> | ||||||
|  |       <h5>How much more are you willing to pay?</h5> | ||||||
|  |       <div class="row"> | ||||||
|  |         <div class="col"> | ||||||
|  |           <small class="form-text text-muted mb-2"> | ||||||
|  |             Choose the maximum extra transaction fee you're willing to pay to get into the next block.<br> | ||||||
|  |             If the estimated next block rate rises beyond this limit, we will automatically cancel your acceleration request. | ||||||
|  |           </small> | ||||||
|  |           <div class="form-group"> | ||||||
|  |             <div class="fee-card"> | ||||||
|  |               <div class="d-flex mb-0"> | ||||||
|  |                 <ng-container *ngFor="let option of maxRateOptions"> | ||||||
|  |                   <button type="button" class="btn btn-primary flex-grow-1 btn-border btn-sm feerate" [class]="{active: selectFeeRateIndex === option.index}" (click)="setUserBid(option)"> | ||||||
|  |                     <span class="fee">{{ option.fee | number }} <span class="symbol" i18n="shared.sats|sats">sats</span></span> | ||||||
|  |                     <span class="rate">~ <app-fee-rate [fee]="option.rate" rounding="1.0-0"></app-fee-rate></span> | ||||||
|  |                   </button> | ||||||
|  |                 </ng-container> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |    | ||||||
|  |       <h5>Acceleration summary</h5> | ||||||
|  |       <div class="row mb-3"> | ||||||
|  |         <div class="col"> | ||||||
|  |           <div class="table-toggle btn-group btn-group-toggle"> | ||||||
|  |             <div class="btn btn-primary btn-sm" [class.active]="showTable === 'estimated'" (click)="showTable = 'estimated'"> | ||||||
|  |               <span>Estimated cost</span> | ||||||
|  |             </div> | ||||||
|  |             <div class="btn btn-primary btn-sm" [class.active]="showTable === 'maximum'" (click)="showTable = 'maximum'"> | ||||||
|  |               <span>Maximum cost</span> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |           <table class="table table-borderless table-border table-dark table-accelerator"> | ||||||
|  |             <tbody> | ||||||
|  |               <!-- ESTIMATED FEE --> | ||||||
|  |               <ng-container *ngIf="showTable === 'estimated'"> | ||||||
|  |                 <tr class="group-first"> | ||||||
|  |                   <td class="item"> | ||||||
|  |                     Next block market rate | ||||||
|  |                   </td> | ||||||
|  |                   <td class="amt" style="font-size: 20px"> | ||||||
|  |                     {{ estimate.targetFeeRate | number : '1.0-0' }} | ||||||
|  |                   </td> | ||||||
|  |                   <td class="units"><span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></td> | ||||||
|  |                 </tr> | ||||||
|  |                 <tr class="info"> | ||||||
|  |                   <td class="info"> | ||||||
|  |                     <i><small>Estimated extra fee required</small></i> | ||||||
|  |                   </td> | ||||||
|  |                   <td class="amt"> | ||||||
|  |                     {{ math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee) | number }} | ||||||
|  |                   </td> | ||||||
|  |                   <td class="units"> | ||||||
|  |                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||||
|  |                     <span class="fiat"><app-fiat [value]="math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee)"></app-fiat></span> | ||||||
|  |                   </td> | ||||||
|  |                 </tr> | ||||||
|  |               </ng-container> | ||||||
|  |               <!-- USER MAX BID --> | ||||||
|  |               <ng-container *ngIf="showTable === 'maximum'"> | ||||||
|  |                 <tr class="group-first"> | ||||||
|  |                   <td class="item"> | ||||||
|  |                     Your maximum | ||||||
|  |                   </td> | ||||||
|  |                   <td class="amt" style="width: 45%; font-size: 20px"> | ||||||
|  |                     ~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }} | ||||||
|  |                   </td> | ||||||
|  |                   <td class="units"><span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></td> | ||||||
|  |                 </tr> | ||||||
|  |                 <tr class="info"> | ||||||
|  |                   <td class="info"> | ||||||
|  |                     <i><small>The maximum extra transaction fee you could pay</small></i> | ||||||
|  |                   </td> | ||||||
|  |                   <td class="amt"> | ||||||
|  |                     <span> | ||||||
|  |                       {{ userBid | number }} | ||||||
|  |                     </span> | ||||||
|  |                   </td> | ||||||
|  |                   <td class="units"> | ||||||
|  |                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||||
|  |                     <span class="fiat"><app-fiat [value]="userBid"></app-fiat></span> | ||||||
|  |                   </td> | ||||||
|  |                 </tr> | ||||||
|  |               </ng-container> | ||||||
|  |    | ||||||
|  |               <!-- MEMPOOL BASE FEE --> | ||||||
|  |               <tr> | ||||||
|  |                 <td class="item"> | ||||||
|  |                   Mempool Accelerator™ fees | ||||||
|  |                 </td> | ||||||
|  |               </tr> | ||||||
|  |               <tr class="info"> | ||||||
|  |                 <td class="info"> | ||||||
|  |                   <i><small>mempool.space fee</small></i> | ||||||
|  |                 </td> | ||||||
|  |                 <td class="amt"> | ||||||
|  |                   +{{ estimate.mempoolBaseFee | number }} | ||||||
|  |                 </td> | ||||||
|  |                 <td class="units"> | ||||||
|  |                   <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||||
|  |                   <span class="fiat"><app-fiat [value]="estimate.mempoolBaseFee"></app-fiat></span> | ||||||
|  |                 </td> | ||||||
|  |               </tr> | ||||||
|  |               <tr class="info group-last" style="border-bottom: 1px solid lightgrey"> | ||||||
|  |                 <td class="info"> | ||||||
|  |                   <i><small>Transaction vsize fee</small></i> | ||||||
|  |                 </td> | ||||||
|  |                 <td class="amt"> | ||||||
|  |                   +{{ estimate.vsizeFee | number }} | ||||||
|  |                 </td> | ||||||
|  |                 <td class="units"> | ||||||
|  |                   <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||||
|  |                   <span class="fiat"><app-fiat [value]="estimate.vsizeFee"></app-fiat></span> | ||||||
|  |                 </td> | ||||||
|  |               </tr> | ||||||
|  | 
 | ||||||
|  |               <!-- NEXT BLOCK ESTIMATE --> | ||||||
|  |               <ng-container *ngIf="showTable === 'estimated'"> | ||||||
|  |                 <tr class="group-first"> | ||||||
|  |                   <td class="item"> | ||||||
|  |                     <b style="background-color: #5E35B1" class="p-1 pl-0">Estimated acceleration cost</b> | ||||||
|  |                   </td> | ||||||
|  |                   <td class="amt"> | ||||||
|  |                     <span style="background-color: #5E35B1" class="p-1 pl-0"> | ||||||
|  |                       {{ estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee | number }} | ||||||
|  |                     </span> | ||||||
|  |                   </td> | ||||||
|  |                   <td class="units"> | ||||||
|  |                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||||
|  |                     <span class="fiat"><app-fiat [value]="estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee"></app-fiat></span> | ||||||
|  |                   </td> | ||||||
|  |                 </tr> | ||||||
|  |                 <tr class="info group-last"> | ||||||
|  |                   <td class="info"> | ||||||
|  |                     <i><small>If your tx is accelerated to </small><small>{{ estimate.targetFeeRate | number : '1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></small></i> | ||||||
|  |                   </td> | ||||||
|  |                 </tr> | ||||||
|  |               </ng-container> | ||||||
|  |    | ||||||
|  |               <!-- MAX COST --> | ||||||
|  |               <ng-container *ngIf="showTable === 'maximum'"> | ||||||
|  |                 <tr class="group-first"> | ||||||
|  |                   <td class="item"> | ||||||
|  |                     <b style="background-color: #105fb0;" class="p-1 pl-0">Maximum acceleration cost</b> | ||||||
|  |                   </td> | ||||||
|  |                   <td class="amt"> | ||||||
|  |                     <span style="background-color: #105fb0" class="p-1 pl-0"> | ||||||
|  |                       {{ maxCost | number }} | ||||||
|  |                     </span> | ||||||
|  |                   </td> | ||||||
|  |                   <td class="units"> | ||||||
|  |                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||||
|  |                     <span class="fiat"> | ||||||
|  |                       <app-fiat [value]="maxCost" [colorClass]="estimate.userBalance < maxCost ? 'red-color' : 'green-color'"></app-fiat> | ||||||
|  |                     </span> | ||||||
|  |                   </td> | ||||||
|  |                 </tr> | ||||||
|  |                 <tr class="info group-last"> | ||||||
|  |                   <td class="info"> | ||||||
|  |                     <i><small>If your tx is accelerated to </small><small>~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }}  <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></small></i> | ||||||
|  |                   </td> | ||||||
|  |                 </tr> | ||||||
|  |               </ng-container> | ||||||
|  |    | ||||||
|  |               <!-- USER BALANCE --> | ||||||
|  |               <ng-container *ngIf="estimate.userBalance < maxCost"> | ||||||
|  |                 <tr class="group-first group-last" style="border-top: 1px dashed grey"> | ||||||
|  |                   <td class="item"> | ||||||
|  |                     Available balance | ||||||
|  |                   </td> | ||||||
|  |                   <td class="amt"> | ||||||
|  |                     {{ estimate.userBalance | number }} | ||||||
|  |                   </td> | ||||||
|  |                   <td class="units"> | ||||||
|  |                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||||
|  |                     <span class="fiat"> | ||||||
|  |                       <app-fiat [value]="estimate.userBalance" [colorClass]="estimate.userBalance < maxCost ? 'red-color' : 'green-color'"></app-fiat> | ||||||
|  |                     </span> | ||||||
|  |                   </td> | ||||||
|  |                 </tr> | ||||||
|  |               </ng-container> | ||||||
|  |             </tbody> | ||||||
|  |           </table> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |    | ||||||
|  |       <div class="row mb-3" *ngIf="isLoggedIn()"> | ||||||
|  |         <div class="col"> | ||||||
|  |           <div class="d-flex justify-content-end"> | ||||||
|  |             <button class="btn btn-sm btn-primary btn-success" style="width: 150px" (click)="accelerate()">Accelerate</button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |    | ||||||
|  |     </div> | ||||||
|  |   </ng-container> | ||||||
|  | </div> | ||||||
| @ -0,0 +1,88 @@ | |||||||
|  | .fee-card { | ||||||
|  |   padding: 15px; | ||||||
|  |   background-color: #1d1f31; | ||||||
|  | 
 | ||||||
|  |   .feerate { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  | 
 | ||||||
|  |     .fee { | ||||||
|  |       font-size: 1.2em; | ||||||
|  |     } | ||||||
|  |     .rate { | ||||||
|  |       font-size: 0.9em; | ||||||
|  |       .symbol { | ||||||
|  |         color: white; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .btn-border { | ||||||
|  |   border: solid 1px black; | ||||||
|  |   background-color: #0c4a87; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .feerate.active { | ||||||
|  |   background-color: #105fb0 !important; | ||||||
|  |   opacity: 1; | ||||||
|  |   border: 1px solid white !important; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .estimateDisabled { | ||||||
|  |   opacity: 0.5; | ||||||
|  |   pointer-events: none; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table-toggle { | ||||||
|  |   width: 100%; | ||||||
|  |   margin-top: 0.5em; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table-accelerator { | ||||||
|  |   tr { | ||||||
|  |     text-wrap: wrap; | ||||||
|  | 
 | ||||||
|  |     td { | ||||||
|  |       padding-top: 0; | ||||||
|  |       padding-bottom: 0; | ||||||
|  |       vertical-align: baseline; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &.group-first { | ||||||
|  |       td { | ||||||
|  |         padding-top: 0.75rem; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     &.group-last { | ||||||
|  |       td { | ||||||
|  |         padding-bottom: 0.75rem; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   td { | ||||||
|  |     &:first-child { | ||||||
|  |       width: 100vw; | ||||||
|  |     } | ||||||
|  |     &.info { | ||||||
|  |       color: #6c757d; | ||||||
|  |     } | ||||||
|  |     &.amt { | ||||||
|  |       text-align: right; | ||||||
|  |       padding-right: 0.2em; | ||||||
|  |     } | ||||||
|  |     &.units { | ||||||
|  |       padding-left: 0.2em; | ||||||
|  |       white-space: nowrap; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .accelerate-cols { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: row; | ||||||
|  |   align-items: stretch; | ||||||
|  |   margin-top: 1em; | ||||||
|  | } | ||||||
| @ -0,0 +1,205 @@ | |||||||
|  | import { Component, OnInit, Input, OnDestroy, OnChanges, SimpleChanges, HostListener } from '@angular/core'; | ||||||
|  | import { ApiService } from '../../services/api.service'; | ||||||
|  | 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'; | ||||||
|  | 
 | ||||||
|  | export type AccelerationEstimate = { | ||||||
|  |   txSummary: TxSummary; | ||||||
|  |   nextBlockFee: number; | ||||||
|  |   targetFeeRate: number; | ||||||
|  |   userBalance: number; | ||||||
|  |   enoughBalance: boolean; | ||||||
|  |   cost: number; | ||||||
|  |   mempoolBaseFee: number; | ||||||
|  |   vsizeFee: number; | ||||||
|  | } | ||||||
|  | export type TxSummary = { | ||||||
|  |   txid: string; // txid of the current transaction
 | ||||||
|  |   effectiveVsize: number; // Total vsize of the dependency tree
 | ||||||
|  |   effectiveFee: number;  // Total fee of the dependency tree in sats
 | ||||||
|  |   ancestorCount: number; // Number of ancestors
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface RateOption { | ||||||
|  |   fee: number; | ||||||
|  |   rate: number; | ||||||
|  |   index: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const MIN_BID_RATIO = 1; | ||||||
|  | export const DEFAULT_BID_RATIO = 2; | ||||||
|  | export const MAX_BID_RATIO = 4; | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-accelerate-preview', | ||||||
|  |   templateUrl: 'accelerate-preview.component.html', | ||||||
|  |   styleUrls: ['accelerate-preview.component.scss'] | ||||||
|  | }) | ||||||
|  | export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges { | ||||||
|  |   @Input() tx: Transaction | undefined; | ||||||
|  |   @Input() scrollEvent: boolean; | ||||||
|  | 
 | ||||||
|  |   math = Math; | ||||||
|  |   error = ''; | ||||||
|  |   showSuccess = false; | ||||||
|  |   estimateSubscription: Subscription; | ||||||
|  |   accelerationSubscription: Subscription; | ||||||
|  |   estimate: any; | ||||||
|  |   hasAncestors: boolean = false; | ||||||
|  |   minExtraCost = 0; | ||||||
|  |   minBidAllowed = 0; | ||||||
|  |   maxBidAllowed = 0; | ||||||
|  |   defaultBid = 0; | ||||||
|  |   maxCost = 0; | ||||||
|  |   userBid = 0; | ||||||
|  |   selectFeeRateIndex = 1; | ||||||
|  |   showTable: 'estimated' | 'maximum' = 'maximum'; | ||||||
|  |   isMobile: boolean = window.innerWidth <= 767.98; | ||||||
|  | 
 | ||||||
|  |   maxRateOptions: RateOption[] = []; | ||||||
|  | 
 | ||||||
|  |   constructor( | ||||||
|  |     private apiService: ApiService, | ||||||
|  |     private storageService: StorageService | ||||||
|  |   ) { } | ||||||
|  | 
 | ||||||
|  |   ngOnDestroy(): void { | ||||||
|  |     if (this.estimateSubscription) { | ||||||
|  |       this.estimateSubscription.unsubscribe(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   ngOnChanges(changes: SimpleChanges): void { | ||||||
|  |     if (changes.scrollEvent) { | ||||||
|  |       this.scrollToPreview('acceleratePreviewAnchor', 'center'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   ngOnInit() { | ||||||
|  |     this.estimateSubscription = this.apiService.estimate$(this.tx.txid).pipe( | ||||||
|  |       tap((response) => { | ||||||
|  |         if (response.status === 204) { | ||||||
|  |           this.estimate = undefined; | ||||||
|  |           this.error = `cannot_accelerate_tx`; | ||||||
|  |           this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||||
|  |           this.estimateSubscription.unsubscribe(); | ||||||
|  |         } else { | ||||||
|  |           this.estimate = response.body; | ||||||
|  |           if (!this.estimate) { | ||||||
|  |             this.error = `cannot_accelerate_tx`; | ||||||
|  |             this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||||
|  |             this.estimateSubscription.unsubscribe(); | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|  |           if (this.estimate.userBalance <= 0) { | ||||||
|  |             if (this.isLoggedIn()) { | ||||||
|  |               this.error = `not_enough_balance`; | ||||||
|  |               this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|  |           this.hasAncestors = this.estimate.txSummary.ancestorCount > 1; | ||||||
|  |            | ||||||
|  |           // Make min extra fee at least 50% of the current tx fee
 | ||||||
|  |           this.minExtraCost = nextRoundNumber(Math.max(this.estimate.cost * 2, this.estimate.txSummary.effectiveFee)); | ||||||
|  | 
 | ||||||
|  |           this.maxRateOptions = [1, 2, 4].map((multiplier, index) => { | ||||||
|  |             return { | ||||||
|  |               fee: this.minExtraCost * multiplier, | ||||||
|  |               rate: (this.estimate.txSummary.effectiveFee + (this.minExtraCost * multiplier)) / this.estimate.txSummary.effectiveVsize, | ||||||
|  |               index, | ||||||
|  |             }; | ||||||
|  |           }); | ||||||
|  | 
 | ||||||
|  |           this.minBidAllowed = this.minExtraCost * MIN_BID_RATIO; | ||||||
|  |           this.defaultBid = this.minExtraCost * DEFAULT_BID_RATIO; | ||||||
|  |           this.maxBidAllowed = this.minExtraCost * MAX_BID_RATIO; | ||||||
|  | 
 | ||||||
|  |           this.userBid = this.defaultBid; | ||||||
|  |           if (this.userBid < this.minBidAllowed) { | ||||||
|  |             this.userBid = this.minBidAllowed; | ||||||
|  |           } else if (this.userBid > this.maxBidAllowed) { | ||||||
|  |             this.userBid = this.maxBidAllowed; | ||||||
|  |           }             | ||||||
|  |           this.maxCost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; | ||||||
|  | 
 | ||||||
|  |           if (!this.error) { | ||||||
|  |             this.scrollToPreview('acceleratePreviewAnchor', 'center'); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }), | ||||||
|  |       catchError((response) => { | ||||||
|  |         this.estimate = undefined; | ||||||
|  |         this.error = response.error; | ||||||
|  |         this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||||
|  |         this.estimateSubscription.unsubscribe(); | ||||||
|  |         return of(null); | ||||||
|  |       }) | ||||||
|  |     ).subscribe(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * User changed his bid | ||||||
|  |    */ | ||||||
|  |   setUserBid({ fee, index }: { fee: number, index: number}) { | ||||||
|  |     if (this.estimate) { | ||||||
|  |       this.selectFeeRateIndex = index; | ||||||
|  |       this.userBid = Math.max(0, fee); | ||||||
|  |       this.maxCost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Scroll to element id with or without setTimeout | ||||||
|  |    */ | ||||||
|  |   scrollToPreviewWithTimeout(id: string, position: ScrollLogicalPosition) { | ||||||
|  |     setTimeout(() => { | ||||||
|  |       this.scrollToPreview(id, position); | ||||||
|  |     }, 100); | ||||||
|  |   } | ||||||
|  |   scrollToPreview(id: string, position: ScrollLogicalPosition) { | ||||||
|  |     const acceleratePreviewAnchor = document.getElementById(id); | ||||||
|  |     if (acceleratePreviewAnchor) { | ||||||
|  |       acceleratePreviewAnchor.scrollIntoView({ | ||||||
|  |         behavior: 'smooth', | ||||||
|  |         inline: position, | ||||||
|  |         block: position, | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Send acceleration request | ||||||
|  |    */ | ||||||
|  |   accelerate() { | ||||||
|  |     if (this.accelerationSubscription) { | ||||||
|  |       this.accelerationSubscription.unsubscribe(); | ||||||
|  |     } | ||||||
|  |     this.accelerationSubscription = this.apiService.accelerate$( | ||||||
|  |       this.tx.txid, | ||||||
|  |       this.userBid | ||||||
|  |     ).subscribe({ | ||||||
|  |       next: () => { | ||||||
|  |         this.showSuccess = true; | ||||||
|  |         this.scrollToPreviewWithTimeout('successAlert', 'center'); | ||||||
|  |         this.estimateSubscription.unsubscribe(); | ||||||
|  |       }, | ||||||
|  |       error: (response) => { | ||||||
|  |         this.error = response.error; | ||||||
|  |         this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   isLoggedIn() { | ||||||
|  |     const auth = this.storageService.getAuth(); | ||||||
|  |     return auth !== null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @HostListener('window:resize', ['$event']) | ||||||
|  |   onResize(): void { | ||||||
|  |     this.isMobile = window.innerWidth <= 767.98; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -9,6 +9,7 @@ import { AudioService } from '../../services/audio.service'; | |||||||
| import { ApiService } from '../../services/api.service'; | import { ApiService } from '../../services/api.service'; | ||||||
| import { of, merge, Subscription, Observable } from 'rxjs'; | import { of, merge, Subscription, Observable } from 'rxjs'; | ||||||
| import { SeoService } from '../../services/seo.service'; | import { SeoService } from '../../services/seo.service'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| import { AddressInformation } from '../../interfaces/node-api.interface'; | import { AddressInformation } from '../../interfaces/node-api.interface'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
| @ -68,6 +69,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { | |||||||
|             this.addressString = this.addressString.toLowerCase(); |             this.addressString = this.addressString.toLowerCase(); | ||||||
|           } |           } | ||||||
|           this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); |           this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${this.addressString}:INTERPOLATION:.`); | ||||||
| 
 | 
 | ||||||
|           return (this.addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) |           return (this.addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) | ||||||
|               ? this.electrsApiService.getPubKeyAddress$(this.addressString) |               ? this.electrsApiService.getPubKeyAddress$(this.addressString) | ||||||
|  | |||||||
| @ -9,6 +9,7 @@ import { AudioService } from '../../services/audio.service'; | |||||||
| import { ApiService } from '../../services/api.service'; | import { ApiService } from '../../services/api.service'; | ||||||
| import { of, merge, Subscription, Observable } from 'rxjs'; | import { of, merge, Subscription, Observable } from 'rxjs'; | ||||||
| import { SeoService } from '../../services/seo.service'; | import { SeoService } from '../../services/seo.service'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| import { AddressInformation } from '../../interfaces/node-api.interface'; | import { AddressInformation } from '../../interfaces/node-api.interface'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
| @ -76,6 +77,7 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|             this.addressString = this.addressString.toLowerCase(); |             this.addressString = this.addressString.toLowerCase(); | ||||||
|           } |           } | ||||||
|           this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); |           this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${this.addressString}:INTERPOLATION:.`); | ||||||
| 
 | 
 | ||||||
|           return merge( |           return merge( | ||||||
|             of(true), |             of(true), | ||||||
| @ -91,6 +93,7 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|                 catchError((err) => { |                 catchError((err) => { | ||||||
|                   this.isLoadingAddress = false; |                   this.isLoadingAddress = false; | ||||||
|                   this.error = err; |                   this.error = err; | ||||||
|  |                   this.seoService.logSoft404(); | ||||||
|                   console.log(err); |                   console.log(err); | ||||||
|                   return of(null); |                   return of(null); | ||||||
|                 }) |                 }) | ||||||
| @ -162,6 +165,7 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|       (error) => { |       (error) => { | ||||||
|         console.log(error); |         console.log(error); | ||||||
|         this.error = error; |         this.error = error; | ||||||
|  |         this.seoService.logSoft404(); | ||||||
|         this.isLoadingAddress = false; |         this.isLoadingAddress = false; | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -86,6 +86,7 @@ export class AssetComponent implements OnInit, OnDestroy { | |||||||
|                   catchError((err) => { |                   catchError((err) => { | ||||||
|                     this.isLoadingAsset = false; |                     this.isLoadingAsset = false; | ||||||
|                     this.error = err; |                     this.error = err; | ||||||
|  |                     this.seoService.logSoft404(); | ||||||
|                     console.log(err); |                     console.log(err); | ||||||
|                     return of(null); |                     return of(null); | ||||||
|                   }) |                   }) | ||||||
| @ -153,6 +154,7 @@ export class AssetComponent implements OnInit, OnDestroy { | |||||||
|       (error) => { |       (error) => { | ||||||
|         console.log(error); |         console.log(error); | ||||||
|         this.error = error; |         this.error = error; | ||||||
|  |         this.seoService.logSoft404(); | ||||||
|         this.isLoadingAsset = false; |         this.isLoadingAsset = false; | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -40,6 +40,7 @@ export class AssetsNavComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.seoService.setTitle($localize`:@@ee8f8008bae6ce3a49840c4e1d39b4af23d4c263:Assets`); |     this.seoService.setTitle($localize`:@@ee8f8008bae6ce3a49840c4e1d39b4af23d4c263:Assets`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.liquid.assets:Explore all the assets issued on the Liquid network like L-BTC, L-CAD, USDT, and more.`); | ||||||
|     this.typeaheadSearchFn = this.typeaheadSearch; |     this.typeaheadSearchFn = this.typeaheadSearch; | ||||||
| 
 | 
 | ||||||
|     this.searchForm = this.formBuilder.group({ |     this.searchForm = this.formBuilder.group({ | ||||||
|  | |||||||
| @ -64,6 +64,7 @@ export class BlockFeeRatesGraphComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.seoService.setTitle($localize`:@@ed8e33059967f554ff06b4f5b6049c465b92d9b3:Block Fee Rates`); |     this.seoService.setTitle($localize`:@@ed8e33059967f554ff06b4f5b6049c465b92d9b3:Block Fee Rates`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fee-rates:See Bitcoin feerates visualized over time, including minimum and maximum feerates per block along with feerates at various percentiles.`); | ||||||
|     this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); |     this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); | ||||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); |     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); |     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||||
|  | |||||||
| @ -65,6 +65,7 @@ export class BlockFeesGraphComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.seoService.setTitle($localize`:@@6c453b11fd7bd159ae30bc381f367bc736d86909:Block Fees`); |     this.seoService.setTitle($localize`:@@6c453b11fd7bd159ae30bc381f367bc736d86909:Block Fees`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fees:See the average mining fees earned per Bitcoin block visualized in BTC and USD over time.`); | ||||||
|     this.miningWindowPreference = this.miningService.getDefaultTimespan('1m'); |     this.miningWindowPreference = this.miningService.getDefaultTimespan('1m'); | ||||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); |     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); |     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||||
| @ -192,7 +193,7 @@ export class BlockFeesGraphComponent implements OnInit { | |||||||
|           { |           { | ||||||
|             name: 'Fees ' + this.currency, |             name: 'Fees ' + this.currency, | ||||||
|             inactiveColor: 'rgb(110, 112, 121)', |             inactiveColor: 'rgb(110, 112, 121)', | ||||||
|             textStyle: {   |             textStyle: { | ||||||
|               color: 'white', |               color: 'white', | ||||||
|             }, |             }, | ||||||
|             icon: 'roundRect', |             icon: 'roundRect', | ||||||
|  | |||||||
| @ -61,6 +61,7 @@ export class BlockHealthGraphComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.seoService.setTitle($localize`:@@d7d5fcf50179ad70c938491c517efb82de2c8146:Block Health`); |     this.seoService.setTitle($localize`:@@d7d5fcf50179ad70c938491c517efb82de2c8146:Block Health`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-health:See Bitcoin block health visualized over time. Block health is a measure of how many expected transactions were included in an actual mined block. Expected transactions are determined using Mempool's re-implementation of Bitcoin Core's transaction selection algorithm.`); | ||||||
|     this.miningWindowPreference = '24h';//this.miningService.getDefaultTimespan('24h');
 |     this.miningWindowPreference = '24h';//this.miningService.getDefaultTimespan('24h');
 | ||||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); |     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); |     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||||
|  | |||||||
| @ -70,9 +70,11 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | |||||||
|     this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false); |     this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false); | ||||||
|     this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false); |     this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false); | ||||||
|     this.gl = this.canvas.nativeElement.getContext('webgl'); |     this.gl = this.canvas.nativeElement.getContext('webgl'); | ||||||
|     this.initCanvas(); |  | ||||||
| 
 | 
 | ||||||
|     this.resizeCanvas(); |     if (this.gl) { | ||||||
|  |       this.initCanvas(); | ||||||
|  |       this.resizeCanvas(); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   ngOnChanges(changes): void { |   ngOnChanges(changes): void { | ||||||
| @ -195,10 +197,16 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | |||||||
|     cancelAnimationFrame(this.animationFrameRequest); |     cancelAnimationFrame(this.animationFrameRequest); | ||||||
|     this.animationFrameRequest = null; |     this.animationFrameRequest = null; | ||||||
|     this.running = false; |     this.running = false; | ||||||
|  |     this.gl = null; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleContextRestored(event): void { |   handleContextRestored(event): void { | ||||||
|     this.initCanvas(); |     if (this.canvas?.nativeElement) { | ||||||
|  |       this.gl = this.canvas.nativeElement.getContext('webgl'); | ||||||
|  |       if (this.gl) { | ||||||
|  |         this.initCanvas(); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @HostListener('window:resize', ['$event']) |   @HostListener('window:resize', ['$event']) | ||||||
| @ -224,6 +232,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   compileShader(src, type): WebGLShader { |   compileShader(src, type): WebGLShader { | ||||||
|  |     if (!this.gl) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|     const shader = this.gl.createShader(type); |     const shader = this.gl.createShader(type); | ||||||
| 
 | 
 | ||||||
|     this.gl.shaderSource(shader, src); |     this.gl.shaderSource(shader, src); | ||||||
| @ -237,6 +248,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   buildShaderProgram(shaderInfo): WebGLProgram { |   buildShaderProgram(shaderInfo): WebGLProgram { | ||||||
|  |     if (!this.gl) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|     const program = this.gl.createProgram(); |     const program = this.gl.createProgram(); | ||||||
| 
 | 
 | ||||||
|     shaderInfo.forEach((desc) => { |     shaderInfo.forEach((desc) => { | ||||||
| @ -273,7 +287,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | |||||||
|       now = performance.now(); |       now = performance.now(); | ||||||
|     } |     } | ||||||
|     // skip re-render if there's no change to the scene
 |     // skip re-render if there's no change to the scene
 | ||||||
|     if (this.scene) { |     if (this.scene && this.gl) { | ||||||
|       /* SET UP SHADER UNIFORMS */ |       /* SET UP SHADER UNIFORMS */ | ||||||
|       // screen dimensions
 |       // screen dimensions
 | ||||||
|       this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight); |       this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight); | ||||||
|  | |||||||
| @ -63,6 +63,7 @@ export class BlockRewardsGraphComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.seoService.setTitle($localize`:@@8ba8fe810458280a83df7fdf4c614dfc1a826445:Block Rewards`); |     this.seoService.setTitle($localize`:@@8ba8fe810458280a83df7fdf4c614dfc1a826445:Block Rewards`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-rewards:See Bitcoin block rewards in BTC and USD visualized over time. Block rewards are the total funds miners earn from the block subsidy and fees.`); | ||||||
|     this.miningWindowPreference = this.miningService.getDefaultTimespan('3m'); |     this.miningWindowPreference = this.miningService.getDefaultTimespan('3m'); | ||||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); |     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); |     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||||
| @ -191,7 +192,7 @@ export class BlockRewardsGraphComponent implements OnInit { | |||||||
|           { |           { | ||||||
|             name: 'Rewards ' + this.currency, |             name: 'Rewards ' + this.currency, | ||||||
|             inactiveColor: 'rgb(110, 112, 121)', |             inactiveColor: 'rgb(110, 112, 121)', | ||||||
|             textStyle: {   |             textStyle: { | ||||||
|               color: 'white', |               color: 'white', | ||||||
|             }, |             }, | ||||||
|             icon: 'roundRect', |             icon: 'roundRect', | ||||||
|  | |||||||
| @ -60,6 +60,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit { | |||||||
|     let firstRun = true; |     let firstRun = true; | ||||||
| 
 | 
 | ||||||
|     this.seoService.setTitle($localize`:@@56fa1cd221491b6478998679cba2dc8d55ba330d:Block Sizes and Weights`); |     this.seoService.setTitle($localize`:@@56fa1cd221491b6478998679cba2dc8d55ba330d:Block Sizes and Weights`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-sizes:See Bitcoin block sizes (MB) and block weights (weight units) visualized over time.`); | ||||||
|     this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); |     this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); | ||||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); |     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); |     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ import { SeoService } from '../../services/seo.service'; | |||||||
| import { OpenGraphService } from '../../services/opengraph.service'; | import { OpenGraphService } from '../../services/opengraph.service'; | ||||||
| import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; | import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; | ||||||
| import { ApiService } from '../../services/api.service'; | import { ApiService } from '../../services/api.service'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; | import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
| @ -82,6 +83,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { | |||||||
|               }), |               }), | ||||||
|               catchError((err) => { |               catchError((err) => { | ||||||
|                 this.error = err; |                 this.error = err; | ||||||
|  |                 this.seoService.logSoft404(); | ||||||
|                 this.openGraphService.fail('block-data-' + this.rawId); |                 this.openGraphService.fail('block-data-' + this.rawId); | ||||||
|                 this.openGraphService.fail('block-viz-' + this.rawId); |                 this.openGraphService.fail('block-viz-' + this.rawId); | ||||||
|                 return of(null); |                 return of(null); | ||||||
| @ -96,6 +98,11 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { | |||||||
|         this.blockHeight = block.height; |         this.blockHeight = block.height; | ||||||
| 
 | 
 | ||||||
|         this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`); |         this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`); | ||||||
|  |         if( this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ) { | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.liquid.block:See size, weight, fee range, included transactions, and more for Liquid${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); | ||||||
|  |         } else { | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.bitcoin.block:See size, weight, fee range, included transactions, audit (expected v actual), and more for Bitcoin${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); | ||||||
|  |         } | ||||||
|         this.isLoadingBlock = false; |         this.isLoadingBlock = false; | ||||||
|         this.setBlockSubsidy(); |         this.setBlockSubsidy(); | ||||||
|         if (block?.extras?.reward !== undefined) { |         if (block?.extras?.reward !== undefined) { | ||||||
| @ -138,6 +145,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { | |||||||
|     (error) => { |     (error) => { | ||||||
|       this.error = error; |       this.error = error; | ||||||
|       this.isLoadingOverview = false; |       this.isLoadingOverview = false; | ||||||
|  |       this.seoService.logSoft404(); | ||||||
|       this.openGraphService.fail('block-viz-' + this.rawId); |       this.openGraphService.fail('block-viz-' + this.rawId); | ||||||
|       this.openGraphService.fail('block-data-' + this.rawId); |       this.openGraphService.fail('block-data-' + this.rawId); | ||||||
|       if (this.blockGraph) { |       if (this.blockGraph) { | ||||||
|  | |||||||
| @ -13,6 +13,7 @@ import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces | |||||||
| import { ApiService } from '../../services/api.service'; | import { ApiService } from '../../services/api.service'; | ||||||
| import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; | import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; | ||||||
| import { detectWebGL } from '../../shared/graphs.utils'; | import { detectWebGL } from '../../shared/graphs.utils'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| import { PriceService, Price } from '../../services/price.service'; | import { PriceService, Price } from '../../services/price.service'; | ||||||
| import { CacheService } from '../../services/cache.service'; | import { CacheService } from '../../services/cache.service'; | ||||||
| 
 | 
 | ||||||
| @ -206,6 +207,7 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|                       this.error = err; |                       this.error = err; | ||||||
|                       this.isLoadingBlock = false; |                       this.isLoadingBlock = false; | ||||||
|                       this.isLoadingOverview = false; |                       this.isLoadingOverview = false; | ||||||
|  |                       this.seoService.logSoft404(); | ||||||
|                       return EMPTY; |                       return EMPTY; | ||||||
|                     }) |                     }) | ||||||
|                   ); |                   ); | ||||||
| @ -214,6 +216,7 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|                   this.error = err; |                   this.error = err; | ||||||
|                   this.isLoadingBlock = false; |                   this.isLoadingBlock = false; | ||||||
|                   this.isLoadingOverview = false; |                   this.isLoadingOverview = false; | ||||||
|  |                   this.seoService.logSoft404(); | ||||||
|                   return EMPTY; |                   return EMPTY; | ||||||
|                 }), |                 }), | ||||||
|               ); |               ); | ||||||
| @ -229,6 +232,7 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|               this.error = err; |               this.error = err; | ||||||
|               this.isLoadingBlock = false; |               this.isLoadingBlock = false; | ||||||
|               this.isLoadingOverview = false; |               this.isLoadingOverview = false; | ||||||
|  |               this.seoService.logSoft404(); | ||||||
|               return EMPTY; |               return EMPTY; | ||||||
|             }) |             }) | ||||||
|           ); |           ); | ||||||
| @ -258,6 +262,11 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|         this.setNextAndPreviousBlockLink(); |         this.setNextAndPreviousBlockLink(); | ||||||
| 
 | 
 | ||||||
|         this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`); |         this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`); | ||||||
|  |         if( this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ) { | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.liquid.block:See size, weight, fee range, included transactions, and more for Liquid${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); | ||||||
|  |         } else { | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.bitcoin.block:See size, weight, fee range, included transactions, audit (expected v actual), and more for Bitcoin${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); | ||||||
|  |         } | ||||||
|         this.isLoadingBlock = false; |         this.isLoadingBlock = false; | ||||||
|         this.setBlockSubsidy(); |         this.setBlockSubsidy(); | ||||||
|         if (block?.extras?.reward !== undefined) { |         if (block?.extras?.reward !== undefined) { | ||||||
| @ -322,7 +331,7 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|         ]); |         ]); | ||||||
|       }) |       }) | ||||||
|     ) |     ) | ||||||
|     .subscribe(([transactions, blockAudit]) => {       |     .subscribe(([transactions, blockAudit]) => { | ||||||
|       if (transactions) { |       if (transactions) { | ||||||
|         this.strippedTransactions = transactions; |         this.strippedTransactions = transactions; | ||||||
|       } else { |       } else { | ||||||
| @ -677,7 +686,7 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|       this.setAuditAvailable(false); |       this.setAuditAvailable(false); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|    | 
 | ||||||
|   isAuditAvailableFromBlockHeight(blockHeight: number): boolean { |   isAuditAvailableFromBlockHeight(blockHeight: number): boolean { | ||||||
|     if (!this.auditSupported) { |     if (!this.auditSupported) { | ||||||
|       return false; |       return false; | ||||||
| @ -726,4 +735,4 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|       this.block.canonical = block.id; |       this.block.canonical = block.id; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, HostListener, ChangeDetectorRef } from '@angular/core'; | import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, HostListener, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core'; | ||||||
| import { firstValueFrom, Subscription } from 'rxjs'; | import { firstValueFrom, Subscription } from 'rxjs'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
| 
 | 
 | ||||||
| @ -8,12 +8,13 @@ import { StateService } from '../../services/state.service'; | |||||||
|   styleUrls: ['./blockchain.component.scss'], |   styleUrls: ['./blockchain.component.scss'], | ||||||
|   changeDetection: ChangeDetectionStrategy.OnPush, |   changeDetection: ChangeDetectionStrategy.OnPush, | ||||||
| }) | }) | ||||||
| export class BlockchainComponent implements OnInit, OnDestroy { | export class BlockchainComponent implements OnInit, OnDestroy, OnChanges { | ||||||
|   @Input() pages: any[] = []; |   @Input() pages: any[] = []; | ||||||
|   @Input() pageIndex: number; |   @Input() pageIndex: number; | ||||||
|   @Input() blocksPerPage: number = 8; |   @Input() blocksPerPage: number = 8; | ||||||
|   @Input() minScrollWidth: number = 0; |   @Input() minScrollWidth: number = 0; | ||||||
|   @Input() scrollableMempool: boolean = false; |   @Input() scrollableMempool: boolean = false; | ||||||
|  |   @Input() containerWidth: number; | ||||||
| 
 | 
 | ||||||
|   @Output() mempoolOffsetChange: EventEmitter<number> = new EventEmitter(); |   @Output() mempoolOffsetChange: EventEmitter<number> = new EventEmitter(); | ||||||
| 
 | 
 | ||||||
| @ -85,19 +86,25 @@ export class BlockchainComponent implements OnInit, OnDestroy { | |||||||
|     this.mempoolOffsetChange.emit(this.mempoolOffset); |     this.mempoolOffsetChange.emit(this.mempoolOffset); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @HostListener('window:resize', ['$event']) |   ngOnChanges(changes: SimpleChanges): void { | ||||||
|  |     if (changes.containerWidth) { | ||||||
|  |       this.onResize(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   onResize(): void { |   onResize(): void { | ||||||
|     if (window.innerWidth >= 768) { |     const width = this.containerWidth || window.innerWidth; | ||||||
|  |     if (width >= 768) { | ||||||
|       if (this.stateService.isLiquid()) { |       if (this.stateService.isLiquid()) { | ||||||
|         this.dividerOffset = 420; |         this.dividerOffset = 420; | ||||||
|       } else { |       } else { | ||||||
|         this.dividerOffset = window.innerWidth * 0.5; |         this.dividerOffset = width * 0.5; | ||||||
|       } |       } | ||||||
|     } else { |     } else { | ||||||
|       if (this.stateService.isLiquid()) { |       if (this.stateService.isLiquid()) { | ||||||
|         this.dividerOffset = window.innerWidth * 0.5; |         this.dividerOffset = width * 0.5; | ||||||
|       } else { |       } else { | ||||||
|         this.dividerOffset = window.innerWidth * 0.95; |         this.dividerOffset = width * 0.95; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     this.cd.markForCheck(); |     this.cd.markForCheck(); | ||||||
|  | |||||||
| @ -5,6 +5,8 @@ import { BlockExtended } from '../../interfaces/node-api.interface'; | |||||||
| import { ApiService } from '../../services/api.service'; | import { ApiService } from '../../services/api.service'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
| import { WebsocketService } from '../../services/websocket.service'; | import { WebsocketService } from '../../services/websocket.service'; | ||||||
|  | import { SeoService } from '../../services/seo.service'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-blocks-list', |   selector: 'app-blocks-list', | ||||||
| @ -35,6 +37,7 @@ export class BlocksList implements OnInit { | |||||||
|     private websocketService: WebsocketService, |     private websocketService: WebsocketService, | ||||||
|     public stateService: StateService, |     public stateService: StateService, | ||||||
|     private cd: ChangeDetectorRef, |     private cd: ChangeDetectorRef, | ||||||
|  |     private seoService: SeoService, | ||||||
|   ) { |   ) { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -50,6 +53,14 @@ export class BlocksList implements OnInit { | |||||||
|     this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()]; |     this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()]; | ||||||
|     this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; |     this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; | ||||||
| 
 | 
 | ||||||
|  |     this.seoService.setTitle($localize`:@@meta.title.blocks-list:Blocks`); | ||||||
|  |     if( this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet' ) { | ||||||
|  |       this.seoService.setDescription($localize`:@@meta.description.liquid.blocks:See the most recent Liquid${seoDescriptionNetwork(this.stateService.network)} blocks along with basic stats such as block height, block size, and more.`); | ||||||
|  |     } else { | ||||||
|  |       this.seoService.setDescription($localize`:@@meta.description.bitcoin.blocks:See the most recent Bitcoin${seoDescriptionNetwork(this.stateService.network)} blocks along with basic stats such as block height, block reward, block size, and more.`); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|     this.blocks$ = combineLatest([ |     this.blocks$ = combineLatest([ | ||||||
|       this.fromHeightSubject.pipe( |       this.fromHeightSubject.pipe( | ||||||
|         switchMap((fromBlockHeight) => { |         switchMap((fromBlockHeight) => { | ||||||
| @ -129,4 +140,4 @@ export class BlocksList implements OnInit { | |||||||
|   isEllipsisActive(e): boolean { |   isEllipsisActive(e): boolean { | ||||||
|     return (e.offsetWidth < e.scrollWidth); |     return (e.offsetWidth < e.scrollWidth); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| <div [formGroup]="fiatForm" class="text-small text-center"> | <div [formGroup]="fiatForm" class="text-small text-center"> | ||||||
|     <select formControlName="fiat" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 200px;" (change)="changeFiat()"> |     <select formControlName="fiat" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 85px;" (change)="changeFiat()"> | ||||||
|         <option *ngFor="let currency of currencies" [value]="currency[1].code">{{ currency[1].name + " (" + currency[1].code + ")" }}</option> |         <option *ngFor="let currency of currencies" [value]="currency[1].code">{{ currency[1].code }}</option> | ||||||
|     </select> |     </select> | ||||||
| </div> | </div> | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ import { MiningService } from '../../services/mining.service'; | |||||||
| import { download } from '../../shared/graphs.utils'; | import { download } from '../../shared/graphs.utils'; | ||||||
| import { ActivatedRoute } from '@angular/router'; | import { ActivatedRoute } from '@angular/router'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-hashrate-chart', |   selector: 'app-hashrate-chart', | ||||||
| @ -71,6 +72,7 @@ export class HashrateChartComponent implements OnInit { | |||||||
|       this.miningWindowPreference = '1y'; |       this.miningWindowPreference = '1y'; | ||||||
|     } else { |     } else { | ||||||
|       this.seoService.setTitle($localize`:@@3510fc6daa1d975f331e3a717bdf1a34efa06dff:Hashrate & Difficulty`); |       this.seoService.setTitle($localize`:@@3510fc6daa1d975f331e3a717bdf1a34efa06dff:Hashrate & Difficulty`); | ||||||
|  |       this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.hashrate:See hashrate and difficulty for the Bitcoin${seoDescriptionNetwork(this.network)} network visualized over time.`); | ||||||
|       this.miningWindowPreference = this.miningService.getDefaultTimespan('3m'); |       this.miningWindowPreference = this.miningService.getDefaultTimespan('3m'); | ||||||
|     } |     } | ||||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); |     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||||
| @ -256,7 +258,7 @@ export class HashrateChartComponent implements OnInit { | |||||||
|               let difficultyPowerOfTen = hashratePowerOfTen; |               let difficultyPowerOfTen = hashratePowerOfTen; | ||||||
|               let difficulty = tick.data[1]; |               let difficulty = tick.data[1]; | ||||||
|               if (difficulty === null) { |               if (difficulty === null) { | ||||||
|                 difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`;   |                 difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`; | ||||||
|               } else { |               } else { | ||||||
|                 if (this.isMobile()) { |                 if (this.isMobile()) { | ||||||
|                   difficultyPowerOfTen = selectPowerOfTen(tick.data[1]); |                   difficultyPowerOfTen = selectPowerOfTen(tick.data[1]); | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| <div [formGroup]="languageForm" class="text-small text-center"> | <div [formGroup]="languageForm" class="text-small text-center"> | ||||||
|     <select formControlName="language" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 200px;" (change)="changeLanguage()"> |     <select formControlName="language" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 95px;" (change)="changeLanguage()"> | ||||||
|         <option *ngFor="let lang of languages" [value]="lang.code">{{ lang.name }}</option> |         <option *ngFor="let lang of languages" [value]="lang.code">{{ lang.name }}</option> | ||||||
|     </select> |     </select> | ||||||
| </div> | </div> | ||||||
|  | |||||||
| @ -1,6 +1,20 @@ | |||||||
| <ng-container *ngIf="{ val: network$ | async } as network"> | <ng-container *ngIf="{ val: network$ | async } as network"> | ||||||
| <header *ngIf="headerVisible"> | <header *ngIf="headerVisible" class="sticky-header"> | ||||||
|  | 
 | ||||||
|   <nav class="navbar navbar-expand-md navbar-dark bg-dark"> |   <nav class="navbar navbar-expand-md navbar-dark bg-dark"> | ||||||
|  |   <!-- Hamburger --> | ||||||
|  |   <ng-container *ngIf="servicesEnabled"> | ||||||
|  |     <div *ngIf="user" class="profile_image_container" [class]="{'anon': !user.imageMd5}" (click)="hamburgerClick($event)"> | ||||||
|  |       <img *ngIf="user.imageMd5" [src]="'/api/v1/services/account/image/' + user.username + '?md5=' + user.imageMd5" class="profile_image"> | ||||||
|  |       <app-svg-images style="color: lightgrey; fill: lightgray" *ngIf="!user.imageMd5" name="anon"></app-svg-images> | ||||||
|  |     </div> | ||||||
|  |     <div *ngIf="false && user === null" class="profile_image_container" (click)="hamburgerClick($event)"> | ||||||
|  |       <app-svg-images name="hamburger" height="40"></app-svg-images> | ||||||
|  |     </div> | ||||||
|  |     <!-- Empty placeholder --> | ||||||
|  |     <div *ngIf="user === undefined" class="profile_image_container"></div> | ||||||
|  |   </ng-container> | ||||||
|  | 
 | ||||||
|   <a class="navbar-brand" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)"> |   <a class="navbar-brand" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)"> | ||||||
|   <ng-template [ngIf]="subdomain"> |   <ng-template [ngIf]="subdomain"> | ||||||
|     <div class="subdomain_container"> |     <div class="subdomain_container"> | ||||||
| @ -62,11 +76,19 @@ | |||||||
| </nav> | </nav> | ||||||
| </header> | </header> | ||||||
| 
 | 
 | ||||||
| <app-testnet-alert *ngIf="network.val === 'testnet' || network.val === 'signet'"></app-testnet-alert> | <div class="d-flex" style="overflow: clip"> | ||||||
|  |   <app-menu *ngIf="servicesEnabled" [navOpen]="menuOpen" (loggedOut)="onLoggedOut()" (menuToggled)="menuToggled($event)"></app-menu> | ||||||
| 
 | 
 | ||||||
| <main> |   <div class="flex-grow-1 d-flex flex-column"> | ||||||
|   <router-outlet></router-outlet> |     <app-testnet-alert *ngIf="network.val === 'testnet' || network.val === 'signet'"></app-testnet-alert> | ||||||
| </main> | 
 | ||||||
|  |     <main style="min-width: 375px" [style]="menuOpen ? 'max-width: calc(100vw - 225px)' : 'max-width: 100vw'"> | ||||||
|  |       <router-outlet></router-outlet> | ||||||
|  |     </main> | ||||||
|  | 
 | ||||||
|  |     <div class="flex-grow-1"></div> | ||||||
|  |     <app-global-footer *ngIf="footerVisible"></app-global-footer> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| 
 | 
 | ||||||
| <app-global-footer *ngIf="footerVisible"></app-global-footer> |  | ||||||
| </ng-container> | </ng-container> | ||||||
|  | |||||||
| @ -1,3 +1,11 @@ | |||||||
|  | .sticky-header { | ||||||
|  |   position: sticky; | ||||||
|  |   position: -webkit-sticky; | ||||||
|  |   top: 0; | ||||||
|  |   width: 100%; | ||||||
|  |   z-index: 100; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| li.nav-item.active { | li.nav-item.active { | ||||||
|   background-color: #653b9c; |   background-color: #653b9c; | ||||||
| } | } | ||||||
| @ -86,7 +94,6 @@ li.nav-item { | |||||||
| 
 | 
 | ||||||
| .navbar-brand { | .navbar-brand { | ||||||
|   position: relative; |   position: relative; | ||||||
|   height: 65px; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .navbar-brand.dual-logos { | .navbar-brand.dual-logos { | ||||||
| @ -102,7 +109,7 @@ nav { | |||||||
| 
 | 
 | ||||||
| .connection-badge { | .connection-badge { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   top: 22px; |   top: 12px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -209,4 +216,26 @@ nav { | |||||||
|     margin-left: 5px; |     margin-left: 5px; | ||||||
|     margin-right: 0px; |     margin-right: 0px; | ||||||
|   } |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .profile_image_container { | ||||||
|  |   width: 35px; | ||||||
|  |   margin-right: 15px; | ||||||
|  |   text-align: center; | ||||||
|  |   align-self: center; | ||||||
|  |   cursor: pointer; | ||||||
|  |   &.anon { | ||||||
|  |     border: 1.5px solid lightgrey; | ||||||
|  |     color: lightgrey; | ||||||
|  |     border-radius: 5px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | .profile_image { | ||||||
|  |   height: 35px; | ||||||
|  |   border-radius: 5px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | main { | ||||||
|  |   transition: 0.2s; | ||||||
|  |   transition-property: max-width; | ||||||
| } | } | ||||||
| @ -1,9 +1,13 @@ | |||||||
| import { Component, OnInit, Input } from '@angular/core'; | import { Component, OnInit, Input, ViewChild } from '@angular/core'; | ||||||
|  | import { Router } from '@angular/router'; | ||||||
| import { Env, StateService } from '../../services/state.service'; | import { Env, StateService } from '../../services/state.service'; | ||||||
| import { Observable, merge, of } from 'rxjs'; | import { Observable, merge, of } from 'rxjs'; | ||||||
| import { LanguageService } from '../../services/language.service'; | import { LanguageService } from '../../services/language.service'; | ||||||
| import { EnterpriseService } from '../../services/enterprise.service'; | import { EnterpriseService } from '../../services/enterprise.service'; | ||||||
| import { NavigationService } from '../../services/navigation.service'; | import { NavigationService } from '../../services/navigation.service'; | ||||||
|  | import { MenuComponent } from '../menu/menu.component'; | ||||||
|  | import { StorageService } from '../../services/storage.service'; | ||||||
|  | import { ApiService } from '../../services/api.service'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-master-page', |   selector: 'app-master-page', | ||||||
| @ -25,12 +29,21 @@ export class MasterPageComponent implements OnInit { | |||||||
|   networkPaths: { [network: string]: string }; |   networkPaths: { [network: string]: string }; | ||||||
|   networkPaths$: Observable<Record<string, string>>; |   networkPaths$: Observable<Record<string, string>>; | ||||||
|   footerVisible = true; |   footerVisible = true; | ||||||
|  |   user: any = undefined; | ||||||
|  |   servicesEnabled = false; | ||||||
|  |   menuOpen = false; | ||||||
|  | 
 | ||||||
|  |   @ViewChild(MenuComponent) | ||||||
|  |   public menuComponent!: MenuComponent; | ||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     public stateService: StateService, |     public stateService: StateService, | ||||||
|     private languageService: LanguageService, |     private languageService: LanguageService, | ||||||
|     private enterpriseService: EnterpriseService, |     private enterpriseService: EnterpriseService, | ||||||
|     private navigationService: NavigationService, |     private navigationService: NavigationService, | ||||||
|  |     private storageService: StorageService, | ||||||
|  |     private apiService: ApiService, | ||||||
|  |     private router: Router, | ||||||
|   ) { } |   ) { } | ||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
| @ -51,17 +64,47 @@ export class MasterPageComponent implements OnInit { | |||||||
|         this.footerVisible = this.footerVisibleOverride; |         this.footerVisible = this.footerVisibleOverride; | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  |      | ||||||
|  |     this.servicesEnabled = this.officialMempoolSpace && this.stateService.env.ACCELERATOR === true && this.stateService.network === ''; | ||||||
|  |     this.refreshAuth(); | ||||||
|  | 
 | ||||||
|  |     const isServicesPage = this.router.url.includes('/services/'); | ||||||
|  |     this.menuOpen = isServicesPage && !this.isSmallScreen(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   collapse(): void { |   collapse(): void { | ||||||
|     this.navCollapsed = !this.navCollapsed; |     this.navCollapsed = !this.navCollapsed; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   isSmallScreen() { | ||||||
|  |     return window.innerWidth <= 767.98; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   onResize(): void { |   onResize(): void { | ||||||
|     this.isMobile = window.innerWidth <= 767.98; |     this.isMobile = this.isSmallScreen(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   brandClick(e): void { |   brandClick(e): void { | ||||||
|     this.stateService.resetScroll$.next(true); |     this.stateService.resetScroll$.next(true); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   onLoggedOut(): void { | ||||||
|  |     this.refreshAuth(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   refreshAuth(): void { | ||||||
|  |     this.user = this.storageService.getAuth()?.user ?? null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   hamburgerClick(event): void { | ||||||
|  |     if (this.menuComponent) { | ||||||
|  |       this.menuComponent.hamburgerClick(); | ||||||
|  |       this.menuOpen = this.menuComponent.navOpen; | ||||||
|  |       event.stopPropagation(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   menuToggled(isOpen: boolean): void { | ||||||
|  |     this.menuOpen = isOpen; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ import { switchMap, map, tap, filter } from 'rxjs/operators'; | |||||||
| import { MempoolBlock, TransactionStripped } from '../../interfaces/websocket.interface'; | import { MempoolBlock, TransactionStripped } from '../../interfaces/websocket.interface'; | ||||||
| import { Observable, BehaviorSubject } from 'rxjs'; | import { Observable, BehaviorSubject } from 'rxjs'; | ||||||
| import { SeoService } from '../../services/seo.service'; | import { SeoService } from '../../services/seo.service'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| import { WebsocketService } from '../../services/websocket.service'; | import { WebsocketService } from '../../services/websocket.service'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
| @ -54,6 +55,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { | |||||||
|                 const ordinal = this.getOrdinal(mempoolBlocks[this.mempoolBlockIndex]); |                 const ordinal = this.getOrdinal(mempoolBlocks[this.mempoolBlockIndex]); | ||||||
|                 this.ordinal$.next(ordinal); |                 this.ordinal$.next(ordinal); | ||||||
|                 this.seoService.setTitle(ordinal); |                 this.seoService.setTitle(ordinal); | ||||||
|  |                 this.seoService.setDescription($localize`:@@meta.description.mempool-block:See stats for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} transactions in the mempool: fee range, aggregate size, and more. Mempool blocks are updated in real-time as the network receives new transactions.`); | ||||||
|                 mempoolBlocks[this.mempoolBlockIndex].isStack = mempoolBlocks[this.mempoolBlockIndex].blockVSize > this.stateService.blockVSize; |                 mempoolBlocks[this.mempoolBlockIndex].isStack = mempoolBlocks[this.mempoolBlockIndex].blockVSize > this.stateService.blockVSize; | ||||||
|                 return mempoolBlocks[this.mempoolBlockIndex]; |                 return mempoolBlocks[this.mempoolBlockIndex]; | ||||||
|               }) |               }) | ||||||
|  | |||||||
| @ -31,6 +31,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|   @Input() spotlight: number = 0; |   @Input() spotlight: number = 0; | ||||||
|   @Input() getHref?: (index) => string = (index) => `/mempool-block/${index}`; |   @Input() getHref?: (index) => string = (index) => `/mempool-block/${index}`; | ||||||
|   @Input() allBlocks: boolean = false; |   @Input() allBlocks: boolean = false; | ||||||
|  |   @Input() forceRtl: boolean = false; | ||||||
| 
 | 
 | ||||||
|   mempoolWidth: number = 0; |   mempoolWidth: number = 0; | ||||||
|   @Output() widthChange: EventEmitter<number> = new EventEmitter(); |   @Output() widthChange: EventEmitter<number> = new EventEmitter(); | ||||||
| @ -102,7 +103,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { |     this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { | ||||||
|       this.timeLtr = !!ltr; |       this.timeLtr = !this.forceRtl && !!ltr; | ||||||
|       this.cd.markForCheck(); |       this.cd.markForCheck(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
| @ -114,11 +115,6 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|     }); |     }); | ||||||
|     this.reduceEmptyBlocksToFitScreen(this.mempoolEmptyBlocks); |     this.reduceEmptyBlocksToFitScreen(this.mempoolEmptyBlocks); | ||||||
| 
 | 
 | ||||||
|     this.mempoolBlocks.map(() => { |  | ||||||
|       this.updateMempoolBlockStyles(); |  | ||||||
|       this.calculateTransactionPosition(); |  | ||||||
|     }); |  | ||||||
|     this.reduceMempoolBlocksToFitScreen(this.mempoolBlocks); |  | ||||||
|     this.isTabHiddenSubscription = this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); |     this.isTabHiddenSubscription = this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); | ||||||
|     this.loadingBlocks$ = combineLatest([ |     this.loadingBlocks$ = combineLatest([ | ||||||
|       this.stateService.isLoadingWebSocket$, |       this.stateService.isLoadingWebSocket$, | ||||||
| @ -206,14 +202,17 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|         if (!block) { |         if (!block) { | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
|  | 
 | ||||||
|  |         const isNewBlock = block.height > this.chainTip; | ||||||
|  | 
 | ||||||
|         if (this.chainTip === -1) { |         if (this.chainTip === -1) { | ||||||
|           this.animateEntry = block.height === this.stateService.latestBlockHeight; |           this.animateEntry = block.height === this.stateService.latestBlockHeight; | ||||||
|         } else { |         } else { | ||||||
|           this.animateEntry = block.height > this.chainTip; |           this.animateEntry = isNewBlock; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         this.chainTip = this.stateService.latestBlockHeight; |         this.chainTip = this.stateService.latestBlockHeight; | ||||||
|         if ((block?.extras?.similarity == null || block?.extras?.similarity > 0.5) && !this.tabHidden) { |         if (isNewBlock && (block?.extras?.similarity == null || block?.extras?.similarity > 0.5) && !this.tabHidden) { | ||||||
|           this.blockIndex++; |           this.blockIndex++; | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
| @ -283,7 +282,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   reduceEmptyBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { |   reduceEmptyBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { | ||||||
|     const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2; |     const innerWidth = this.containerWidth || (this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2); | ||||||
|     let blocksAmount = this.stateService.env.MEMPOOL_BLOCKS_AMOUNT; |     let blocksAmount = this.stateService.env.MEMPOOL_BLOCKS_AMOUNT; | ||||||
|     if (!this.allBlocks) { |     if (!this.allBlocks) { | ||||||
|       blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding))); |       blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding))); | ||||||
| @ -306,7 +305,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { |   reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { | ||||||
|     const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2; |     const innerWidth = this.containerWidth || (this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2); | ||||||
|     let blocksAmount = this.stateService.env.MEMPOOL_BLOCKS_AMOUNT; |     let blocksAmount = this.stateService.env.MEMPOOL_BLOCKS_AMOUNT; | ||||||
|     if (this.count) { |     if (this.count) { | ||||||
|       blocksAmount = 8; |       blocksAmount = 8; | ||||||
| @ -316,7 +315,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|     while (blocks.length > blocksAmount) { |     while (blocks.length > blocksAmount) { | ||||||
|       const block = blocks.pop(); |       const block = blocks.pop(); | ||||||
|       if (!this.count) { |       if (!this.count) { | ||||||
|         const lastBlock = blocks[0]; |         const lastBlock = blocks[blocks.length - 1]; | ||||||
|         lastBlock.blockSize += block.blockSize; |         lastBlock.blockSize += block.blockSize; | ||||||
|         lastBlock.blockVSize += block.blockVSize; |         lastBlock.blockVSize += block.blockVSize; | ||||||
|         lastBlock.nTx += block.nTx; |         lastBlock.nTx += block.nTx; | ||||||
| @ -327,7 +326,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     if (blocks.length) { |     if (blocks.length) { | ||||||
|       blocks[0].isStack = blocks[0].blockVSize > this.stateService.blockVSize; |       blocks[blocks.length - 1].isStack = blocks[blocks.length - 1].blockVSize > this.stateService.blockVSize; | ||||||
|     } |     } | ||||||
|     return blocks; |     return blocks; | ||||||
|   } |   } | ||||||
|  | |||||||
							
								
								
									
										31
									
								
								frontend/src/app/components/menu/menu.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								frontend/src/app/components/menu/menu.component.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | |||||||
|  | <div class="sidenav menu-click" [class]="navOpen ? 'open': ''"> | ||||||
|  |   <div class="d-flex menu-click"> | ||||||
|  | 
 | ||||||
|  |     <nav class="scrollable menu-click"> | ||||||
|  |       <span *ngIf="userAuth" class="menu-click"> | ||||||
|  |         <strong class="menu-click">@ {{ userAuth.user.username }}</strong> | ||||||
|  |       </span> | ||||||
|  |       <a *ngIf="!userAuth" class="d-flex justify-content-center align-items-center nav-link m-0  menu-click" routerLink="/login" role="tab" (click)="onLinkClick('/login')"> | ||||||
|  |         <fa-icon class="menu-click" [icon]="['fas', 'user-circle']" [fixedWidth]="true" style="font-size: 25px;margin-right: 15px;"></fa-icon> | ||||||
|  |         <span class="menu-click" style="font-size: 20px;">Sign in</span> | ||||||
|  |       </a> | ||||||
|  | 
 | ||||||
|  |       <ng-container *ngIf="userMenuGroups$ | async as menuGroups"> | ||||||
|  |         <div class="menu-click" *ngFor="let group of menuGroups" style="height: max-content;"> | ||||||
|  |           <h6 class="d-flex justify-content-between align-items-center mt-4 mb-2 text-uppercase menu-click"> | ||||||
|  |             <span class="menu-click">{{ group.title }}</span> | ||||||
|  |           </h6> | ||||||
|  |           <ul class="nav flex-column menu-click" *ngFor="let item of group.items" (click)="onLinkClick(item.link)"> | ||||||
|  |             <li class="nav-item d-flex justify-content-start align-items-center menu-click"> | ||||||
|  |               <fa-icon class="menu-click" [icon]="['fas', item.faIcon]" [fixedWidth]="true"></fa-icon> | ||||||
|  |               <button *ngIf="item.link === 'logout'" class="btn nav-link menu-click" role="tab" (click)="logout()">{{ item.title }}</button> | ||||||
|  |               <a *ngIf="item.title !== 'Logout'" class="nav-link menu-click" [routerLink]="[item.link]" role="tab">{{ item.title }}</a> | ||||||
|  |             </li> | ||||||
|  |           </ul> | ||||||
|  |         </div> | ||||||
|  |       </ng-container> | ||||||
|  |     </nav> | ||||||
|  | 
 | ||||||
|  |   </div> | ||||||
|  | 
 | ||||||
|  | </div> | ||||||
							
								
								
									
										48
									
								
								frontend/src/app/components/menu/menu.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								frontend/src/app/components/menu/menu.component.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | |||||||
|  | .sidenav { | ||||||
|  |   z-index: 1; | ||||||
|  |   background-color: transparent; | ||||||
|  |   width: 225px; | ||||||
|  |   height: calc(100vh - 65px); | ||||||
|  |   position: sticky; | ||||||
|  |   top: 65px; | ||||||
|  |   transition: 0.25s; | ||||||
|  |   margin-left: -250px; | ||||||
|  |   box-shadow: 5px 0px 30px 0px #000; | ||||||
|  |   padding-bottom: 20px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .scrollable { | ||||||
|  |   overflow-x: hidden; | ||||||
|  |   overflow-y: scroll; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .sidenav.open { | ||||||
|  |   margin-left: 0px; | ||||||
|  |   left: 0px; | ||||||
|  |   display: block; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .sidenav a, button{ | ||||||
|  |   text-decoration: none; | ||||||
|  |   color: lightgray; | ||||||
|  |   margin-left: 20px; | ||||||
|  | } | ||||||
|  | .sidenav a:hover { | ||||||
|  |   color: white; | ||||||
|  | } | ||||||
|  | .sidenav nav { | ||||||
|  |   width: 100%; | ||||||
|  |   height: calc(100vh - 65px); | ||||||
|  |   background-color: #1d1f31; | ||||||
|  |   padding-left: 20px; | ||||||
|  |   padding-right: 20px; | ||||||
|  |   padding-top: 20px; | ||||||
|  |   padding-bottom: 20px; | ||||||
|  |   @media (max-width: 991px) { | ||||||
|  |     padding-bottom: 200px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @media screen and (max-height: 450px) { | ||||||
|  |   .sidenav a {font-size: 18px;} | ||||||
|  | } | ||||||
							
								
								
									
										101
									
								
								frontend/src/app/components/menu/menu.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								frontend/src/app/components/menu/menu.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,101 @@ | |||||||
|  | import { Component, OnInit, Input, Output, EventEmitter, HostListener, OnDestroy } from '@angular/core'; | ||||||
|  | import { Observable } from 'rxjs'; | ||||||
|  | import { ApiService } from '../../services/api.service'; | ||||||
|  | import { MenuGroup } from '../../interfaces/services.interface'; | ||||||
|  | import { StorageService } from '../../services/storage.service'; | ||||||
|  | import { Router, NavigationStart } from '@angular/router'; | ||||||
|  | import { StateService } from '../../services/state.service'; | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-menu', | ||||||
|  |   templateUrl: './menu.component.html', | ||||||
|  |   styleUrls: ['./menu.component.scss'] | ||||||
|  | }) | ||||||
|  | 
 | ||||||
|  | export class MenuComponent implements OnInit, OnDestroy { | ||||||
|  |   @Input() navOpen: boolean = false; | ||||||
|  |   @Output() loggedOut = new EventEmitter<boolean>(); | ||||||
|  |   @Output() menuToggled = new EventEmitter<boolean>(); | ||||||
|  |    | ||||||
|  |   userMenuGroups$: Observable<MenuGroup[]> | undefined; | ||||||
|  |   userAuth: any | undefined; | ||||||
|  |   isServicesPage = false; | ||||||
|  | 
 | ||||||
|  |   constructor( | ||||||
|  |     private apiService: ApiService, | ||||||
|  |     private storageService: StorageService, | ||||||
|  |     private router: Router, | ||||||
|  |     private stateService: StateService | ||||||
|  |   ) {} | ||||||
|  | 
 | ||||||
|  |   ngOnInit(): void { | ||||||
|  |     this.userAuth = this.storageService.getAuth(); | ||||||
|  |      | ||||||
|  |     if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) { | ||||||
|  |       this.userMenuGroups$ = this.apiService.getUserMenuGroups$(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.isServicesPage = this.router.url.includes('/services/'); | ||||||
|  |     this.router.events.subscribe((event) => { | ||||||
|  |       if (event instanceof NavigationStart) { | ||||||
|  |         if (!this.isServicesPage) { | ||||||
|  |           this.toggleMenu(false); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   toggleMenu(toggled: boolean) { | ||||||
|  |     this.navOpen = toggled; | ||||||
|  |     this.menuToggled.emit(toggled); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   isSmallScreen() { | ||||||
|  |     return window.innerWidth <= 767.98; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   logout(): void { | ||||||
|  |     this.apiService.logout$().subscribe(() => { | ||||||
|  |       this.loggedOut.emit(true); | ||||||
|  |       if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) { | ||||||
|  |         this.userMenuGroups$ = this.apiService.getUserMenuGroups$(); | ||||||
|  |         this.router.navigateByUrl('/'); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   onLinkClick(link) { | ||||||
|  |     if (!this.isServicesPage || this.isSmallScreen()) { | ||||||
|  |       this.toggleMenu(false); | ||||||
|  |     } | ||||||
|  |     this.router.navigateByUrl(link); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   hamburgerClick() { | ||||||
|  |     this.toggleMenu(!this.navOpen); | ||||||
|  |     this.stateService.menuOpen$.next(this.navOpen); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @HostListener('window:click', ['$event']) | ||||||
|  |   onClick(event) { | ||||||
|  |     const isServicesPageOnMobile = this.isServicesPage && this.isSmallScreen(); | ||||||
|  |     const cssClasses = event.target.className; | ||||||
|  | 
 | ||||||
|  |     if (!cssClasses.indexOf) { // Click on chart or non html thingy, close the menu
 | ||||||
|  |       if (!this.isServicesPage || isServicesPageOnMobile) { | ||||||
|  |         this.toggleMenu(false); | ||||||
|  |       } | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const isHamburger = cssClasses.indexOf('profile_image') !== -1; | ||||||
|  |     const isMenu = cssClasses.indexOf('menu-click') !== -1; | ||||||
|  |     if (!isHamburger && !isMenu && (!this.isServicesPage || isServicesPageOnMobile)) { | ||||||
|  |       this.toggleMenu(false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   ngOnDestroy(): void { | ||||||
|  |     this.stateService.menuOpen$.next(false); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -18,6 +18,7 @@ export class MiningDashboardComponent implements OnInit, AfterViewInit { | |||||||
|     private router: Router |     private router: Router | ||||||
|   ) { |   ) { | ||||||
|     this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Mining Dashboard`); |     this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Mining Dashboard`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.mining.dashboard:Get real-time Bitcoin mining stats like hashrate, difficulty adjustment, block rewards, pool dominance, and more.`); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
| @ -29,7 +30,7 @@ export class MiningDashboardComponent implements OnInit, AfterViewInit { | |||||||
|     this.router.events.subscribe((e: NavigationStart) => { |     this.router.events.subscribe((e: NavigationStart) => { | ||||||
|       if (e.type === EventType.NavigationStart) { |       if (e.type === EventType.NavigationStart) { | ||||||
|         if (e.url.indexOf('graphs') === -1) { // The mining dashboard and the graph component are part of the same module so we can't use ngAfterViewInit in graphs.component.ts to blur the input
 |         if (e.url.indexOf('graphs') === -1) { // The mining dashboard and the graph component are part of the same module so we can't use ngAfterViewInit in graphs.component.ts to blur the input
 | ||||||
|           this.stateService.focusSearchInputDesktop();  |           this.stateService.focusSearchInputDesktop(); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  | |||||||
| @ -56,6 +56,7 @@ export class PoolRankingComponent implements OnInit { | |||||||
|       this.miningWindowPreference = '1w'; |       this.miningWindowPreference = '1w'; | ||||||
|     } else { |     } else { | ||||||
|       this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`); |       this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`); | ||||||
|  |       this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.pool-ranking:See the top Bitcoin mining pools ranked by number of blocks mined, over your desired timeframe.`); | ||||||
|       this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); |       this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); | ||||||
|     } |     } | ||||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); |     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||||
| @ -116,7 +117,7 @@ export class PoolRankingComponent implements OnInit { | |||||||
|     } else if (this.widget) { |     } else if (this.widget) { | ||||||
|       poolShareThreshold = 1; |       poolShareThreshold = 1; | ||||||
|     } |     } | ||||||
|      | 
 | ||||||
|     const data: object[] = []; |     const data: object[] = []; | ||||||
|     let totalShareOther = 0; |     let totalShareOther = 0; | ||||||
|     let totalBlockOther = 0; |     let totalBlockOther = 0; | ||||||
|  | |||||||
| @ -61,6 +61,7 @@ export class PoolPreviewComponent implements OnInit { | |||||||
|               }), |               }), | ||||||
|               catchError(() => { |               catchError(() => { | ||||||
|                 this.isLoading = false; |                 this.isLoading = false; | ||||||
|  |                 this.seoService.logSoft404(); | ||||||
|                 this.openGraphService.fail('pool-hash-' + this.slug); |                 this.openGraphService.fail('pool-hash-' + this.slug); | ||||||
|                 return of([slug]); |                 return of([slug]); | ||||||
|               }) |               }) | ||||||
| @ -70,6 +71,7 @@ export class PoolPreviewComponent implements OnInit { | |||||||
|           return this.apiService.getPoolStats$(slug).pipe( |           return this.apiService.getPoolStats$(slug).pipe( | ||||||
|             catchError(() => { |             catchError(() => { | ||||||
|               this.isLoading = false; |               this.isLoading = false; | ||||||
|  |               this.seoService.logSoft404(); | ||||||
|               this.openGraphService.fail('pool-stats-' + this.slug); |               this.openGraphService.fail('pool-stats-' + this.slug); | ||||||
|               return of(null); |               return of(null); | ||||||
|             }) |             }) | ||||||
| @ -81,6 +83,7 @@ export class PoolPreviewComponent implements OnInit { | |||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           this.seoService.setTitle(poolStats.pool.name); |           this.seoService.setTitle(poolStats.pool.name); | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.mining.pool:See mining pool stats for ${poolStats.pool.name}\: most recent mined blocks, hashrate over time, total block reward to date, known coinbase addresses, and more.`); | ||||||
|           let regexes = '"'; |           let regexes = '"'; | ||||||
|           for (const regex of poolStats.pool.regexes) { |           for (const regex of poolStats.pool.regexes) { | ||||||
|             regexes += regex + '", "'; |             regexes += regex + '", "'; | ||||||
|  | |||||||
| @ -1,8 +1,8 @@ | |||||||
| import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; | import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; | ||||||
| import { ActivatedRoute } from '@angular/router'; | import { ActivatedRoute } from '@angular/router'; | ||||||
| import { EChartsOption, graphic } from 'echarts'; | import { EChartsOption, graphic } from 'echarts'; | ||||||
| import { BehaviorSubject, Observable } from 'rxjs'; | import { BehaviorSubject, Observable, of, timer } from 'rxjs'; | ||||||
| import { distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; | import { catchError, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; | ||||||
| import { BlockExtended, PoolStat } from '../../interfaces/node-api.interface'; | import { BlockExtended, PoolStat } from '../../interfaces/node-api.interface'; | ||||||
| import { ApiService } from '../../services/api.service'; | import { ApiService } from '../../services/api.service'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
| @ -62,16 +62,28 @@ export class PoolComponent implements OnInit { | |||||||
|                 this.prepareChartOptions(data.map(val => [val.timestamp * 1000, val.avgHashrate])); |                 this.prepareChartOptions(data.map(val => [val.timestamp * 1000, val.avgHashrate])); | ||||||
|                 return [slug]; |                 return [slug]; | ||||||
|               }), |               }), | ||||||
|  |               catchError(() => { | ||||||
|  |                 this.isLoading = false; | ||||||
|  |                 this.seoService.logSoft404(); | ||||||
|  |                 return of([slug]); | ||||||
|  |               }) | ||||||
|             ); |             ); | ||||||
|         }), |         }), | ||||||
|         switchMap((slug) => { |         switchMap((slug) => { | ||||||
|           return this.apiService.getPoolStats$(slug); |           return this.apiService.getPoolStats$(slug).pipe( | ||||||
|  |             catchError(() => { | ||||||
|  |               this.isLoading = false; | ||||||
|  |               this.seoService.logSoft404(); | ||||||
|  |               return of(null); | ||||||
|  |             }) | ||||||
|  |           ); | ||||||
|         }), |         }), | ||||||
|         tap(() => { |         tap(() => { | ||||||
|           this.loadMoreSubject.next(this.blocks[0]?.height); |           this.loadMoreSubject.next(this.blocks[0]?.height); | ||||||
|         }), |         }), | ||||||
|         map((poolStats) => { |         map((poolStats) => { | ||||||
|           this.seoService.setTitle(poolStats.pool.name); |           this.seoService.setTitle(poolStats.pool.name); | ||||||
|  |           this.seoService.setDescription($localize`:@@meta.description.mining.pool:See mining pool stats for ${poolStats.pool.name}\: most recent mined blocks, hashrate over time, total block reward to date, known coinbase addresses, and more.`); | ||||||
|           let regexes = '"'; |           let regexes = '"'; | ||||||
|           for (const regex of poolStats.pool.regexes) { |           for (const regex of poolStats.pool.regexes) { | ||||||
|             regexes += regex + '", "'; |             regexes += regex + '", "'; | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| import { Component } from '@angular/core'; | import { Component } from '@angular/core'; | ||||||
| import { Env, StateService } from '../../services/state.service'; | import { Env, StateService } from '../../services/state.service'; | ||||||
|  | import { SeoService } from '../../services/seo.service'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-privacy-policy', |   selector: 'app-privacy-policy', | ||||||
| @ -11,5 +12,11 @@ export class PrivacyPolicyComponent { | |||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     private stateService: StateService, |     private stateService: StateService, | ||||||
|  |     private seoService: SeoService, | ||||||
|   ) { } |   ) { } | ||||||
|  | 
 | ||||||
|  |   ngOnInit(): void { | ||||||
|  |     this.seoService.setTitle('Privacy Policy'); | ||||||
|  |     this.seoService.setDescription('Trusted third parties are security holes, as are trusted first parties...you should only trust your own self-hosted instance of The Mempool Open Source Project™.'); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,6 +1,9 @@ | |||||||
| import { Component, OnInit } from '@angular/core'; | import { Component, OnInit } from '@angular/core'; | ||||||
| import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; | import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; | ||||||
| import { ApiService } from '../../services/api.service'; | import { ApiService } from '../../services/api.service'; | ||||||
|  | import { StateService } from '../../services/state.service'; | ||||||
|  | import { SeoService } from '../../services/seo.service'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-push-transaction', |   selector: 'app-push-transaction', | ||||||
| @ -16,12 +19,17 @@ export class PushTransactionComponent implements OnInit { | |||||||
|   constructor( |   constructor( | ||||||
|     private formBuilder: UntypedFormBuilder, |     private formBuilder: UntypedFormBuilder, | ||||||
|     private apiService: ApiService, |     private apiService: ApiService, | ||||||
|  |     public stateService: StateService, | ||||||
|  |     private seoService: SeoService, | ||||||
|   ) { } |   ) { } | ||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.pushTxForm = this.formBuilder.group({ |     this.pushTxForm = this.formBuilder.group({ | ||||||
|       txHash: ['', Validators.required], |       txHash: ['', Validators.required], | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|  |     this.seoService.setTitle($localize`:@@meta.title.push-tx:Broadcast Transaction`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.push-tx:Broadcast a transaction to the ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} network using the transaction's hash.`); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   postTx() { |   postTx() { | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| <div [formGroup]="rateUnitForm" class="text-small text-center"> | <div [formGroup]="rateUnitForm" class="text-small text-center"> | ||||||
|     <select formControlName="rateUnits" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 200px;" (change)="changeUnits()"> |     <select formControlName="rateUnits" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 95px;" (change)="changeUnits()"> | ||||||
|         <option *ngFor="let unit of units" [value]="unit.name">{{ unit.label }}</option> |         <option *ngFor="let unit of units" [value]="unit.name">{{ unit.label }}</option> | ||||||
|     </select> |     </select> | ||||||
| </div> | </div> | ||||||
|  | |||||||
| @ -6,6 +6,8 @@ import { WebsocketService } from '../../services/websocket.service'; | |||||||
| import { RbfTree } from '../../interfaces/node-api.interface'; | import { RbfTree } from '../../interfaces/node-api.interface'; | ||||||
| import { ApiService } from '../../services/api.service'; | import { ApiService } from '../../services/api.service'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
|  | import { SeoService } from '../../services/seo.service'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-rbf-list', |   selector: 'app-rbf-list', | ||||||
| @ -26,6 +28,7 @@ export class RbfList implements OnInit, OnDestroy { | |||||||
|     private apiService: ApiService, |     private apiService: ApiService, | ||||||
|     public stateService: StateService, |     public stateService: StateService, | ||||||
|     private websocketService: WebsocketService, |     private websocketService: WebsocketService, | ||||||
|  |     private seoService: SeoService, | ||||||
|   ) { } |   ) { } | ||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
| @ -51,9 +54,12 @@ export class RbfList implements OnInit, OnDestroy { | |||||||
|         this.isLoading = false; |         this.isLoading = false; | ||||||
|       }) |       }) | ||||||
|     ); |     ); | ||||||
|  | 
 | ||||||
|  |     this.seoService.setTitle($localize`:@@meta.title.rbf-list:RBF Replacements`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.rbf-list:See the most recent RBF replacements on the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network, updated in real-time.`); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   ngOnDestroy(): void { |   ngOnDestroy(): void { | ||||||
|     this.websocketService.stopTrackRbf(); |     this.websocketService.stopTrackRbf(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -10,15 +10,25 @@ | |||||||
| 
 | 
 | ||||||
| <div *ngIf="countdown > 0" class="warning-label">{{ eventName }} in {{ countdown | number }} block{{ countdown === 1 ? '' : 's' }}!</div> | <div *ngIf="countdown > 0" class="warning-label">{{ eventName }} in {{ countdown | number }} block{{ countdown === 1 ? '' : 's' }}!</div> | ||||||
| 
 | 
 | ||||||
| <div class="blockchain-wrapper" [class.time-ltr]="timeLtr" [class.time-rtl]="!timeLtr"> | <div class="blockchain-wrapper" [class.time-ltr]="timeLtr" [class.time-rtl]="!timeLtr" #blockchainWrapper> | ||||||
|   <div id="blockchain-container" [dir]="timeLtr ? 'rtl' : 'ltr'" #blockchainContainer |   <div id="blockchain-container" [dir]="timeLtr ? 'rtl' : 'ltr'" #blockchainContainer | ||||||
|  |     [class.menu-open]="menuOpen" | ||||||
|  |     [class.menu-closing]="menuSliding && !menuOpen" | ||||||
|     (mousedown)="onMouseDown($event)" |     (mousedown)="onMouseDown($event)" | ||||||
|     (pointerdown)="onPointerDown($event)" |     (pointerdown)="onPointerDown($event)" | ||||||
|     (touchmove)="onTouchMove($event)" |     (touchmove)="onTouchMove($event)" | ||||||
|     (dragstart)="onDragStart($event)" |     (dragstart)="onDragStart($event)" | ||||||
|     (scroll)="onScroll($event)" |     (scroll)="onScroll($event)" | ||||||
|   > |   > | ||||||
|     <app-blockchain [pageIndex]="pageIndex" [pages]="pages" [blocksPerPage]="blocksPerPage" [minScrollWidth]="minScrollWidth" [scrollableMempool]="true" (mempoolOffsetChange)="onMempoolOffsetChange($event)"></app-blockchain> |     <app-blockchain | ||||||
|  |       [containerWidth]="chainWidth" | ||||||
|  |       [pageIndex]="pageIndex" | ||||||
|  |       [pages]="pages" | ||||||
|  |       [blocksPerPage]="blocksPerPage" | ||||||
|  |       [minScrollWidth]="minScrollWidth" | ||||||
|  |       [scrollableMempool]="true" | ||||||
|  |       (mempoolOffsetChange)="onMempoolOffsetChange($event)" | ||||||
|  |     ></app-blockchain> | ||||||
|   </div> |   </div> | ||||||
|   <div class="reset-scroll" [class.hidden]="pageIndex === 0" (click)="resetScroll()"> |   <div class="reset-scroll" [class.hidden]="pageIndex === 0" (click)="resetScroll()"> | ||||||
|     <fa-icon [icon]="['fas', 'circle-left']" [fixedWidth]="true"></fa-icon> |     <fa-icon [icon]="['fas', 'circle-left']" [fixedWidth]="true"></fa-icon> | ||||||
|  | |||||||
| @ -6,6 +6,20 @@ | |||||||
|   overflow-y: hidden; |   overflow-y: hidden; | ||||||
|   scrollbar-width: none; |   scrollbar-width: none; | ||||||
|   -ms-overflow-style: none; |   -ms-overflow-style: none; | ||||||
|  |   width: calc(100% + 120px); | ||||||
|  | 
 | ||||||
|  |   transform: translateX(0px); | ||||||
|  |   transition: transform 0; | ||||||
|  | 
 | ||||||
|  |   &.menu-open { | ||||||
|  |     transform: translateX(-112.5px); | ||||||
|  |     transition: transform 0.25s; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &.menu-closing { | ||||||
|  |     transform: translateX(0px); | ||||||
|  |     transition: transform 0.25s; | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #blockchain-container::-webkit-scrollbar { | #blockchain-container::-webkit-scrollbar { | ||||||
|  | |||||||
| @ -28,8 +28,10 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | |||||||
|   lastMark: MarkBlockState; |   lastMark: MarkBlockState; | ||||||
|   markBlockSubscription: Subscription; |   markBlockSubscription: Subscription; | ||||||
|   blockCounterSubscription: Subscription; |   blockCounterSubscription: Subscription; | ||||||
|  |   @ViewChild('blockchainWrapper', { static: true }) blockchainWrapper: ElementRef; | ||||||
|   @ViewChild('blockchainContainer') blockchainContainer: ElementRef; |   @ViewChild('blockchainContainer') blockchainContainer: ElementRef; | ||||||
|   resetScrollSubscription: Subscription;  |   resetScrollSubscription: Subscription; | ||||||
|  |   menuSubscription: Subscription; | ||||||
| 
 | 
 | ||||||
|   isMobile: boolean = false; |   isMobile: boolean = false; | ||||||
|   isiOS: boolean = false; |   isiOS: boolean = false; | ||||||
| @ -49,6 +51,12 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | |||||||
|   velocity: number = 0; |   velocity: number = 0; | ||||||
|   mempoolOffset: number = 0; |   mempoolOffset: number = 0; | ||||||
| 
 | 
 | ||||||
|  |   private resizeObserver: ResizeObserver; | ||||||
|  |   chainWidth: number = window.innerWidth; | ||||||
|  |   menuOpen: boolean = false; | ||||||
|  |   menuSliding: boolean = false; | ||||||
|  |   menuTimeout: number; | ||||||
|  | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     private stateService: StateService, |     private stateService: StateService, | ||||||
|   ) { |   ) { | ||||||
| @ -151,6 +159,13 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | |||||||
|         this.stateService.resetScroll$.next(false); |         this.stateService.resetScroll$.next(false); | ||||||
|       }  |       }  | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|  |     this.menuSubscription = this.stateService.menuOpen$.subscribe((open) => { | ||||||
|  |       if (this.menuOpen !== open) { | ||||||
|  |         this.menuOpen = open; | ||||||
|  |         this.applyMenuScroll(this.menuOpen); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   onMempoolOffsetChange(offset): void { |   onMempoolOffsetChange(offset): void { | ||||||
| @ -171,9 +186,18 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   applyMenuScroll(opening: boolean): void { | ||||||
|  |     this.menuSliding = true; | ||||||
|  |     window.clearTimeout(this.menuTimeout); | ||||||
|  |     this.menuTimeout = window.setTimeout(() => { | ||||||
|  |       this.menuSliding = false; | ||||||
|  |     }, 300); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   @HostListener('window:resize', ['$event']) |   @HostListener('window:resize', ['$event']) | ||||||
|   onResize(): void { |   onResize(): void { | ||||||
|     this.isMobile = window.innerWidth <= 767.98; |     this.chainWidth = window.innerWidth; | ||||||
|  |     this.isMobile = this.chainWidth <= 767.98; | ||||||
|     let firstVisibleBlock; |     let firstVisibleBlock; | ||||||
|     let offset; |     let offset; | ||||||
|     if (this.blockchainContainer?.nativeElement != null) { |     if (this.blockchainContainer?.nativeElement != null) { | ||||||
| @ -188,7 +212,7 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | |||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     this.blocksPerPage = Math.ceil(window.innerWidth / this.blockWidth); |     this.blocksPerPage = Math.ceil(this.chainWidth / this.blockWidth); | ||||||
|     this.pageWidth = this.blocksPerPage * this.blockWidth; |     this.pageWidth = this.blocksPerPage * this.blockWidth; | ||||||
|     this.minScrollWidth = this.firstPageWidth + (this.pageWidth * 2); |     this.minScrollWidth = this.firstPageWidth + (this.pageWidth * 2); | ||||||
| 
 | 
 | ||||||
| @ -295,7 +319,7 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | |||||||
|   onScroll(e) { |   onScroll(e) { | ||||||
|     const middlePage = this.pageIndex === 0 ? this.pages[0] : this.pages[1]; |     const middlePage = this.pageIndex === 0 ? this.pages[0] : this.pages[1]; | ||||||
|     // compensate for css transform
 |     // compensate for css transform
 | ||||||
|     const translation = (this.isMobile ? window.innerWidth * 0.95 : window.innerWidth * 0.5); |     const translation = (this.isMobile ? this.chainWidth * 0.95 : this.chainWidth * 0.5); | ||||||
|     const backThreshold = middlePage.offset + (this.pageWidth * 0.5) + translation; |     const backThreshold = middlePage.offset + (this.pageWidth * 0.5) + translation; | ||||||
|     const forwardThreshold = middlePage.offset - (this.pageWidth * 0.5) + translation; |     const forwardThreshold = middlePage.offset - (this.pageWidth * 0.5) + translation; | ||||||
|     const scrollLeft = this.getConvertedScrollOffset(); |     const scrollLeft = this.getConvertedScrollOffset(); | ||||||
| @ -414,10 +438,10 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | |||||||
| 
 | 
 | ||||||
|   blockInViewport(height: number): boolean { |   blockInViewport(height: number): boolean { | ||||||
|     const firstHeight = this.pages[0].height; |     const firstHeight = this.pages[0].height; | ||||||
|     const translation = (this.isMobile ? window.innerWidth * 0.95 : window.innerWidth * 0.5); |     const translation = (this.isMobile ? this.chainWidth * 0.95 : this.chainWidth * 0.5); | ||||||
|     const firstX = this.pages[0].offset - this.getConvertedScrollOffset() + translation; |     const firstX = this.pages[0].offset - this.getConvertedScrollOffset() + translation; | ||||||
|     const xPos = firstX + ((firstHeight - height) * 155); |     const xPos = firstX + ((firstHeight - height) * 155); | ||||||
|     return xPos > -55 && xPos < (window.innerWidth - 100); |     return xPos > -55 && xPos < (this.chainWidth - 100); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getConvertedScrollOffset(): number { |   getConvertedScrollOffset(): number { | ||||||
| @ -458,5 +482,6 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | |||||||
|     this.markBlockSubscription.unsubscribe(); |     this.markBlockSubscription.unsubscribe(); | ||||||
|     this.blockCounterSubscription.unsubscribe(); |     this.blockCounterSubscription.unsubscribe(); | ||||||
|     this.resetScrollSubscription.unsubscribe(); |     this.resetScrollSubscription.unsubscribe(); | ||||||
|  |     this.menuSubscription.unsubscribe(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -62,6 +62,7 @@ export class StatisticsComponent implements OnInit { | |||||||
|     this.inverted = this.storageService.getValue('inverted-graph') === 'true'; |     this.inverted = this.storageService.getValue('inverted-graph') === 'true'; | ||||||
|     this.setFeeLevelDropdownData(); |     this.setFeeLevelDropdownData(); | ||||||
|     this.seoService.setTitle($localize`:@@5d4f792f048fcaa6df5948575d7cb325c9393383:Graphs`); |     this.seoService.setTitle($localize`:@@5d4f792f048fcaa6df5948575d7cb325c9393383:Graphs`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.mempool:See mempool size (in MvB) and transactions per second (in vB/s) visualized over time.`); | ||||||
|     this.stateService.networkChanged$.subscribe((network) => this.network = network); |     this.stateService.networkChanged$.subscribe((network) => this.network = network); | ||||||
|     this.graphWindowPreference = this.storageService.getValue('graphWindowPreference') ? this.storageService.getValue('graphWindowPreference').trim() : '2h'; |     this.graphWindowPreference = this.storageService.getValue('graphWindowPreference') ? this.storageService.getValue('graphWindowPreference').trim() : '2h'; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -74,6 +74,16 @@ | |||||||
|       <path fill="#FFFFFF" d="M128 768h256v64H128v-64z m320-384H128v64h320v-64z m128 192V448L384 640l192 192V704h320V576H576z m-288-64H128v64h160v-64zM128 704h160v-64H128v64z m576 64h64v128c-1 18-7 33-19 45s-27 18-45 19H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h192C256 57 313 0 384 0s128 57 128 128h192c35 0 64 29 64 64v320h-64V320H64v576h640V768zM128 256h512c0-35-29-64-64-64h-64c-35 0-64-29-64-64s-29-64-64-64-64 29-64 64-29 64-64 64h-64c-35 0-64 29-64 64z" /> |       <path fill="#FFFFFF" d="M128 768h256v64H128v-64z m320-384H128v64h320v-64z m128 192V448L384 640l192 192V704h320V576H576z m-288-64H128v64h160v-64zM128 704h160v-64H128v64z m576 64h64v128c-1 18-7 33-19 45s-27 18-45 19H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h192C256 57 313 0 384 0s128 57 128 128h192c35 0 64 29 64 64v320h-64V320H64v576h640V768zM128 256h512c0-35-29-64-64-64h-64c-35 0-64-29-64-64s-29-64-64-64-64 29-64 64-29 64-64 64h-64c-35 0-64 29-64 64z" /> | ||||||
|     </svg> |     </svg> | ||||||
|   </ng-container> |   </ng-container> | ||||||
|  |   <ng-container *ngSwitchCase="'hamburger'"> | ||||||
|  |     <svg [attr.width]="width" [attr.height]="height" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg" stroke="currentColor"> | ||||||
|  |       <path stroke-width="0.5" stroke-linecap="round" d="M0.5 2.5 H7 M0.5 5 H5.5 M0.5 7.5 H7"></path> | ||||||
|  |     </svg> | ||||||
|  |   </ng-container> | ||||||
|  |   <ng-container *ngSwitchCase="'anon'"> | ||||||
|  |     <svg [attr.width]="width" [attr.height]="height" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="currentColor"> | ||||||
|  |       <path stroke-linecap="round" stroke-linejoin="round" d="M9 9.5v-2a3 3 0 116 0v2c0 1.11-.603 2.08-1.5 2.599v1.224a1 1 0 00.629.928l2.05.82A3.693 3.693 0 0118.5 18.5h-13c0-1.51.92-2.868 2.321-3.428l2.05-.82a1 1 0 00.629-.929v-1.224A2.999 2.999 0 019 9.5z"></path> | ||||||
|  |     </svg> | ||||||
|  |   </ng-container> | ||||||
| </ng-container> | </ng-container> | ||||||
| 
 | 
 | ||||||
| <ng-template #bitcoinLogo let-color let-width="width" let-height="height" let-viewBox="viewBox"> | <ng-template #bitcoinLogo let-color let-width="width" let-height="height" let-viewBox="viewBox"> | ||||||
|  | |||||||
| @ -37,6 +37,7 @@ export class TelevisionComponent implements OnInit, OnDestroy { | |||||||
| 
 | 
 | ||||||
|   ngOnInit() { |   ngOnInit() { | ||||||
|     this.seoService.setTitle($localize`:@@46ce8155c9ab953edeec97e8950b5a21e67d7c4e:TV view`); |     this.seoService.setTitle($localize`:@@46ce8155c9ab953edeec97e8950b5a21e67d7c4e:TV view`); | ||||||
|  |     this.seoService.setDescription($localize`:@@meta.description.tv:See Bitcoin blocks and mempool congestion in real-time in a simplified format perfect for a TV.`); | ||||||
|     this.websocketService.want(['blocks', 'live-2h-chart', 'mempool-blocks']); |     this.websocketService.want(['blocks', 'live-2h-chart', 'mempool-blocks']); | ||||||
| 
 | 
 | ||||||
|     this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { |     this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| import { Component } from '@angular/core'; | import { Component } from '@angular/core'; | ||||||
| import { Env, StateService } from '../../services/state.service'; | import { Env, StateService } from '../../services/state.service'; | ||||||
|  | import { SeoService } from '../../services/seo.service'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-terms-of-service', |   selector: 'app-terms-of-service', | ||||||
| @ -10,5 +11,11 @@ export class TermsOfServiceComponent { | |||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     private stateService: StateService, |     private stateService: StateService, | ||||||
|  |     private seoService: SeoService, | ||||||
|   ) { } |   ) { } | ||||||
|  | 
 | ||||||
|  |   ngOnInit(): void { | ||||||
|  |     this.seoService.setTitle('Terms of Service'); | ||||||
|  |     this.seoService.setDescription('Out of respect for the Bitcoin community, the mempool.space website is Bitcoin Only and does not display any advertising.'); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| import { Component } from '@angular/core'; | import { Component } from '@angular/core'; | ||||||
| import { Env, StateService } from '../../services/state.service'; | import { Env, StateService } from '../../services/state.service'; | ||||||
|  | import { SeoService } from '../../services/seo.service'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-trademark-policy', |   selector: 'app-trademark-policy', | ||||||
| @ -11,5 +12,11 @@ export class TrademarkPolicyComponent { | |||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     private stateService: StateService, |     private stateService: StateService, | ||||||
|  |     private seoService: SeoService, | ||||||
|   ) { } |   ) { } | ||||||
|  | 
 | ||||||
|  |   ngOnInit(): void { | ||||||
|  |     this.seoService.setTitle('Trademark Policy'); | ||||||
|  |     this.seoService.setDescription('An overview of the trademarks registered by Mempool Space K.K. and The Mempool Open Source Project™ and what we consider to be lawful usage of those trademarks.'); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
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