Merge branch 'master' into nymkappa/tx-overflow
This commit is contained in:
		
						commit
						5959c426f3
					
				
							
								
								
									
										15
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -7,7 +7,8 @@ updates: | |||||||
|     open-pull-requests-limit: 10 |     open-pull-requests-limit: 10 | ||||||
|     ignore: |     ignore: | ||||||
|       - dependency-name: "*" |       - dependency-name: "*" | ||||||
|         update-types: ["version-update:semver-major"] |         update-types: | ||||||
|  |           ["version-update:semver-major", "version-update:semver-patch"] | ||||||
|     allow: |     allow: | ||||||
|       - dependency-type: "production" |       - dependency-type: "production" | ||||||
| 
 | 
 | ||||||
| @ -18,7 +19,8 @@ updates: | |||||||
|     open-pull-requests-limit: 10 |     open-pull-requests-limit: 10 | ||||||
|     ignore: |     ignore: | ||||||
|       - dependency-name: "*" |       - dependency-name: "*" | ||||||
|         update-types: ["version-update:semver-major"] |         update-types: | ||||||
|  |           ["version-update:semver-major", "version-update:semver-patch"] | ||||||
|     allow: |     allow: | ||||||
|       - dependency-type: "production" |       - dependency-type: "production" | ||||||
| 
 | 
 | ||||||
| @ -28,7 +30,8 @@ updates: | |||||||
|       interval: weekly |       interval: weekly | ||||||
|     ignore: |     ignore: | ||||||
|       - dependency-name: "*" |       - dependency-name: "*" | ||||||
|         update-types: ["version-update:semver-major"] |         update-types: | ||||||
|  |           ["version-update:semver-major", "version-update:semver-patch"] | ||||||
| 
 | 
 | ||||||
|   - package-ecosystem: docker |   - package-ecosystem: docker | ||||||
|     directory: "/docker/frontend" |     directory: "/docker/frontend" | ||||||
| @ -36,7 +39,8 @@ updates: | |||||||
|       interval: weekly |       interval: weekly | ||||||
|     ignore: |     ignore: | ||||||
|       - dependency-name: "*" |       - dependency-name: "*" | ||||||
|         update-types: ["version-update:semver-major"] |         update-types: | ||||||
|  |           ["version-update:semver-major", "version-update:semver-patch"] | ||||||
| 
 | 
 | ||||||
|   - package-ecosystem: "github-actions" |   - package-ecosystem: "github-actions" | ||||||
|     directory: "/" |     directory: "/" | ||||||
| @ -44,4 +48,5 @@ updates: | |||||||
|       interval: weekly |       interval: weekly | ||||||
|     ignore: |     ignore: | ||||||
|       - dependency-name: "*" |       - dependency-name: "*" | ||||||
|         update-types: ["version-update:semver-major"] |         update-types: | ||||||
|  |           ["version-update:semver-major", "version-update:semver-patch"] | ||||||
|  | |||||||
							
								
								
									
										11
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @ -9,7 +9,7 @@ jobs: | |||||||
|     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" |     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" | ||||||
|     strategy: |     strategy: | ||||||
|       matrix: |       matrix: | ||||||
|         node: ["16", "17", "18"] |         node: ["16", "17", "18", "20"] | ||||||
|         flavor: ["dev", "prod"] |         flavor: ["dev", "prod"] | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|     runs-on: "ubuntu-latest" |     runs-on: "ubuntu-latest" | ||||||
| @ -28,9 +28,7 @@ jobs: | |||||||
|           registry-url: "https://registry.npmjs.org" |           registry-url: "https://registry.npmjs.org" | ||||||
| 
 | 
 | ||||||
|       - name: Install 1.70.x Rust toolchain |       - name: Install 1.70.x Rust toolchain | ||||||
|         uses: actions-rs/toolchain@v1 |         uses: dtolnay/rust-toolchain@1.70 | ||||||
|         with: |  | ||||||
|             toolchain: 1.70 |  | ||||||
| 
 | 
 | ||||||
|       - name: Install |       - name: Install | ||||||
|         if: ${{ matrix.flavor == 'dev'}} |         if: ${{ matrix.flavor == 'dev'}} | ||||||
| @ -60,7 +58,7 @@ jobs: | |||||||
|     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" |     if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" | ||||||
|     strategy: |     strategy: | ||||||
|       matrix: |       matrix: | ||||||
|         node: ["16", "17", "18"] |         node: ["16", "17", "18", "20"] | ||||||
|         flavor: ["dev", "prod"] |         flavor: ["dev", "prod"] | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|     runs-on: "ubuntu-latest" |     runs-on: "ubuntu-latest" | ||||||
| @ -99,3 +97,6 @@ jobs: | |||||||
|       - name: Build |       - name: Build | ||||||
|         run: npm run build |         run: npm run build | ||||||
|         working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend |         working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend | ||||||
|  |         env:  | ||||||
|  |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||||
|  | 
 | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								LICENSE
									
									
									
									
									
								
							| @ -13,7 +13,7 @@ the terms of (at your option) either: | |||||||
|  proxy statement published on <https://mempool.space/about>. |  proxy statement published on <https://mempool.space/about>. | ||||||
| 
 | 
 | ||||||
| However, this copyright license does not include an implied right or license to | However, this copyright license does not include an implied right or license to | ||||||
| use our trademarks: The Mempool Open Source Project™, mempool.space™, the | use our trademarks: The Mempool Open Source Project®, mempool.space™, the | ||||||
| mempool Logo™, the mempool.space Vertical Logo™, the mempool.space Horizontal | mempool Logo™, the mempool.space Vertical Logo™, the mempool.space Horizontal | ||||||
| Logo™, the mempool Square Logo™, and the mempool Blocks logo™ are registered | Logo™, the mempool Square Logo™, and the mempool Blocks logo™ are registered | ||||||
| trademarks or trademarks of Mempool Space K.K in Japan, the United States, | trademarks or trademarks of Mempool Space K.K in Japan, the United States, | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| # The Mempool Open Source Project™ [](https://dashboard.cypress.io/projects/ry4br7/runs) | # The Mempool Open Source Project® [](https://dashboard.cypress.io/projects/ry4br7/runs) | ||||||
| 
 | 
 | ||||||
| https://user-images.githubusercontent.com/93150691/226236121-375ea64f-b4a1-4cc0-8fad-a6fb33226840.mp4 | https://user-images.githubusercontent.com/93150691/226236121-375ea64f-b4a1-4cc0-8fad-a6fb33226840.mp4 | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								backend/.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								backend/.dockerignore
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | Dockerfile | ||||||
| @ -8,6 +8,7 @@ | |||||||
|     "API_URL_PREFIX": "/api/v1/", |     "API_URL_PREFIX": "/api/v1/", | ||||||
|     "POLL_RATE_MS": 2000, |     "POLL_RATE_MS": 2000, | ||||||
|     "CACHE_DIR": "./cache", |     "CACHE_DIR": "./cache", | ||||||
|  |     "CACHE_ENABLED": true, | ||||||
|     "CLEAR_PROTECTION_MINUTES": 20, |     "CLEAR_PROTECTION_MINUTES": 20, | ||||||
|     "RECOMMENDED_FEE_PERCENTILE": 50, |     "RECOMMENDED_FEE_PERCENTILE": 50, | ||||||
|     "BLOCK_WEIGHT_UNITS": 4000000, |     "BLOCK_WEIGHT_UNITS": 4000000, | ||||||
|  | |||||||
							
								
								
									
										158
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										158
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -19,6 +19,7 @@ | |||||||
|         "maxmind": "~4.3.11", |         "maxmind": "~4.3.11", | ||||||
|         "mysql2": "~3.5.2", |         "mysql2": "~3.5.2", | ||||||
|         "rust-gbt": "file:./rust-gbt", |         "rust-gbt": "file:./rust-gbt", | ||||||
|  |         "redis": "^4.6.6", | ||||||
|         "socks-proxy-agent": "~7.0.0", |         "socks-proxy-agent": "~7.0.0", | ||||||
|         "typescript": "~4.9.3", |         "typescript": "~4.9.3", | ||||||
|         "ws": "~8.13.0" |         "ws": "~8.13.0" | ||||||
| @ -1555,6 +1556,64 @@ | |||||||
|         "node": ">= 8" |         "node": ">= 8" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/@redis/bloom": { | ||||||
|  |       "version": "1.2.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", | ||||||
|  |       "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", | ||||||
|  |       "peerDependencies": { | ||||||
|  |         "@redis/client": "^1.0.0" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "node_modules/@redis/client": { | ||||||
|  |       "version": "1.5.7", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.7.tgz", | ||||||
|  |       "integrity": "sha512-gaOBOuJPjK5fGtxSseaKgSvjiZXQCdLlGg9WYQst+/GRUjmXaiB5kVkeQMRtPc7Q2t93XZcJfBMSwzs/XS9UZw==", | ||||||
|  |       "dependencies": { | ||||||
|  |         "cluster-key-slot": "1.1.2", | ||||||
|  |         "generic-pool": "3.9.0", | ||||||
|  |         "yallist": "4.0.0" | ||||||
|  |       }, | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">=14" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "node_modules/@redis/client/node_modules/yallist": { | ||||||
|  |       "version": "4.0.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", | ||||||
|  |       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" | ||||||
|  |     }, | ||||||
|  |     "node_modules/@redis/graph": { | ||||||
|  |       "version": "1.1.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.0.tgz", | ||||||
|  |       "integrity": "sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg==", | ||||||
|  |       "peerDependencies": { | ||||||
|  |         "@redis/client": "^1.0.0" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "node_modules/@redis/json": { | ||||||
|  |       "version": "1.0.4", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.4.tgz", | ||||||
|  |       "integrity": "sha512-LUZE2Gdrhg0Rx7AN+cZkb1e6HjoSKaeeW8rYnt89Tly13GBI5eP4CwDVr+MY8BAYfCg4/N15OUrtLoona9uSgw==", | ||||||
|  |       "peerDependencies": { | ||||||
|  |         "@redis/client": "^1.0.0" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "node_modules/@redis/search": { | ||||||
|  |       "version": "1.1.2", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.2.tgz", | ||||||
|  |       "integrity": "sha512-/cMfstG/fOh/SsE+4/BQGeuH/JJloeWuH+qJzM8dbxuWvdWibWAOAHHCZTMPhV3xIlH4/cUEIA8OV5QnYpaVoA==", | ||||||
|  |       "peerDependencies": { | ||||||
|  |         "@redis/client": "^1.0.0" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "node_modules/@redis/time-series": { | ||||||
|  |       "version": "1.0.4", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.4.tgz", | ||||||
|  |       "integrity": "sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==", | ||||||
|  |       "peerDependencies": { | ||||||
|  |         "@redis/client": "^1.0.0" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/@sinclair/typebox": { |     "node_modules/@sinclair/typebox": { | ||||||
|       "version": "0.25.24", |       "version": "0.25.24", | ||||||
|       "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", |       "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", | ||||||
| @ -2718,6 +2777,14 @@ | |||||||
|         "node": ">=12" |         "node": ">=12" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/cluster-key-slot": { | ||||||
|  |       "version": "1.1.2", | ||||||
|  |       "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", | ||||||
|  |       "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">=0.10.0" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/co": { |     "node_modules/co": { | ||||||
|       "version": "4.6.0", |       "version": "4.6.0", | ||||||
|       "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", |       "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", | ||||||
| @ -3678,6 +3745,14 @@ | |||||||
|         "is-property": "^1.0.2" |         "is-property": "^1.0.2" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/generic-pool": { | ||||||
|  |       "version": "3.9.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", | ||||||
|  |       "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">= 4" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/gensync": { |     "node_modules/gensync": { | ||||||
|       "version": "1.0.0-beta.2", |       "version": "1.0.0-beta.2", | ||||||
|       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", |       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", | ||||||
| @ -6577,6 +6652,19 @@ | |||||||
|       "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", |       "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", | ||||||
|       "dev": true |       "dev": true | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/redis": { | ||||||
|  |       "version": "4.6.6", | ||||||
|  |       "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.6.tgz", | ||||||
|  |       "integrity": "sha512-aLs2fuBFV/VJ28oLBqYykfnhGGkFxvx0HdCEBYdJ99FFbSEMZ7c1nVKwR6ZRv+7bb7JnC0mmCzaqu8frgOYhpA==", | ||||||
|  |       "dependencies": { | ||||||
|  |         "@redis/bloom": "1.2.0", | ||||||
|  |         "@redis/client": "1.5.7", | ||||||
|  |         "@redis/graph": "1.1.0", | ||||||
|  |         "@redis/json": "1.0.4", | ||||||
|  |         "@redis/search": "1.1.2", | ||||||
|  |         "@redis/time-series": "1.0.4" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/require-directory": { |     "node_modules/require-directory": { | ||||||
|       "version": "2.1.1", |       "version": "2.1.1", | ||||||
|       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", |       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", | ||||||
| @ -8704,6 +8792,53 @@ | |||||||
|         "fastq": "^1.6.0" |         "fastq": "^1.6.0" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "@redis/bloom": { | ||||||
|  |       "version": "1.2.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", | ||||||
|  |       "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", | ||||||
|  |       "requires": {} | ||||||
|  |     }, | ||||||
|  |     "@redis/client": { | ||||||
|  |       "version": "1.5.7", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.7.tgz", | ||||||
|  |       "integrity": "sha512-gaOBOuJPjK5fGtxSseaKgSvjiZXQCdLlGg9WYQst+/GRUjmXaiB5kVkeQMRtPc7Q2t93XZcJfBMSwzs/XS9UZw==", | ||||||
|  |       "requires": { | ||||||
|  |         "cluster-key-slot": "1.1.2", | ||||||
|  |         "generic-pool": "3.9.0", | ||||||
|  |         "yallist": "4.0.0" | ||||||
|  |       }, | ||||||
|  |       "dependencies": { | ||||||
|  |         "yallist": { | ||||||
|  |           "version": "4.0.0", | ||||||
|  |           "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", | ||||||
|  |           "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "@redis/graph": { | ||||||
|  |       "version": "1.1.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.0.tgz", | ||||||
|  |       "integrity": "sha512-16yZWngxyXPd+MJxeSr0dqh2AIOi8j9yXKcKCwVaKDbH3HTuETpDVPcLujhFYVPtYrngSco31BUcSa9TH31Gqg==", | ||||||
|  |       "requires": {} | ||||||
|  |     }, | ||||||
|  |     "@redis/json": { | ||||||
|  |       "version": "1.0.4", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.4.tgz", | ||||||
|  |       "integrity": "sha512-LUZE2Gdrhg0Rx7AN+cZkb1e6HjoSKaeeW8rYnt89Tly13GBI5eP4CwDVr+MY8BAYfCg4/N15OUrtLoona9uSgw==", | ||||||
|  |       "requires": {} | ||||||
|  |     }, | ||||||
|  |     "@redis/search": { | ||||||
|  |       "version": "1.1.2", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.2.tgz", | ||||||
|  |       "integrity": "sha512-/cMfstG/fOh/SsE+4/BQGeuH/JJloeWuH+qJzM8dbxuWvdWibWAOAHHCZTMPhV3xIlH4/cUEIA8OV5QnYpaVoA==", | ||||||
|  |       "requires": {} | ||||||
|  |     }, | ||||||
|  |     "@redis/time-series": { | ||||||
|  |       "version": "1.0.4", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.4.tgz", | ||||||
|  |       "integrity": "sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==", | ||||||
|  |       "requires": {} | ||||||
|  |     }, | ||||||
|     "@sinclair/typebox": { |     "@sinclair/typebox": { | ||||||
|       "version": "0.25.24", |       "version": "0.25.24", | ||||||
|       "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", |       "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", | ||||||
| @ -9604,6 +9739,11 @@ | |||||||
|         "wrap-ansi": "^7.0.0" |         "wrap-ansi": "^7.0.0" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "cluster-key-slot": { | ||||||
|  |       "version": "1.1.2", | ||||||
|  |       "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", | ||||||
|  |       "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" | ||||||
|  |     }, | ||||||
|     "co": { |     "co": { | ||||||
|       "version": "4.6.0", |       "version": "4.6.0", | ||||||
|       "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", |       "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", | ||||||
| @ -10332,6 +10472,11 @@ | |||||||
|         "is-property": "^1.0.2" |         "is-property": "^1.0.2" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "generic-pool": { | ||||||
|  |       "version": "3.9.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", | ||||||
|  |       "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==" | ||||||
|  |     }, | ||||||
|     "gensync": { |     "gensync": { | ||||||
|       "version": "1.0.0-beta.2", |       "version": "1.0.0-beta.2", | ||||||
|       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", |       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", | ||||||
| @ -12454,6 +12599,19 @@ | |||||||
|       "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", |       "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", | ||||||
|       "dev": true |       "dev": true | ||||||
|     }, |     }, | ||||||
|  |     "redis": { | ||||||
|  |       "version": "4.6.6", | ||||||
|  |       "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.6.tgz", | ||||||
|  |       "integrity": "sha512-aLs2fuBFV/VJ28oLBqYykfnhGGkFxvx0HdCEBYdJ99FFbSEMZ7c1nVKwR6ZRv+7bb7JnC0mmCzaqu8frgOYhpA==", | ||||||
|  |       "requires": { | ||||||
|  |         "@redis/bloom": "1.2.0", | ||||||
|  |         "@redis/client": "1.5.7", | ||||||
|  |         "@redis/graph": "1.1.0", | ||||||
|  |         "@redis/json": "1.0.4", | ||||||
|  |         "@redis/search": "1.1.2", | ||||||
|  |         "@redis/time-series": "1.0.4" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "require-directory": { |     "require-directory": { | ||||||
|       "version": "2.1.1", |       "version": "2.1.1", | ||||||
|       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", |       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", | ||||||
|  | |||||||
| @ -47,13 +47,14 @@ | |||||||
|     "maxmind": "~4.3.11", |     "maxmind": "~4.3.11", | ||||||
|     "mysql2": "~3.5.2", |     "mysql2": "~3.5.2", | ||||||
|     "rust-gbt": "file:./rust-gbt", |     "rust-gbt": "file:./rust-gbt", | ||||||
|  |     "redis": "^4.6.6", | ||||||
|     "socks-proxy-agent": "~7.0.0", |     "socks-proxy-agent": "~7.0.0", | ||||||
|     "typescript": "~4.9.3", |     "typescript": "~4.9.3", | ||||||
|     "ws": "~8.13.0" |     "ws": "~8.13.0" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@babel/core": "^7.21.3", |  | ||||||
|     "@babel/code-frame": "^7.18.6", |     "@babel/code-frame": "^7.18.6", | ||||||
|  |     "@babel/core": "^7.21.3", | ||||||
|     "@types/compression": "^1.7.2", |     "@types/compression": "^1.7.2", | ||||||
|     "@types/crypto-js": "^4.1.1", |     "@types/crypto-js": "^4.1.1", | ||||||
|     "@types/express": "^4.17.17", |     "@types/express": "^4.17.17", | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ | |||||||
|     "AUTOMATIC_BLOCK_REINDEXING": false, |     "AUTOMATIC_BLOCK_REINDEXING": false, | ||||||
|     "POLL_RATE_MS": 3, |     "POLL_RATE_MS": 3, | ||||||
|     "CACHE_DIR": "__MEMPOOL_CACHE_DIR__", |     "CACHE_DIR": "__MEMPOOL_CACHE_DIR__", | ||||||
|  |     "CACHE_ENABLED": true, | ||||||
|     "CLEAR_PROTECTION_MINUTES": 4, |     "CLEAR_PROTECTION_MINUTES": 4, | ||||||
|     "RECOMMENDED_FEE_PERCENTILE": 5, |     "RECOMMENDED_FEE_PERCENTILE": 5, | ||||||
|     "BLOCK_WEIGHT_UNITS": 6, |     "BLOCK_WEIGHT_UNITS": 6, | ||||||
| @ -127,5 +128,9 @@ | |||||||
|     "AUDIT": false, |     "AUDIT": false, | ||||||
|     "AUDIT_START_HEIGHT": 774000, |     "AUDIT_START_HEIGHT": 774000, | ||||||
|     "SERVERS": [] |     "SERVERS": [] | ||||||
|  |   }, | ||||||
|  |   "REDIS": { | ||||||
|  |     "ENABLED": false, | ||||||
|  |     "UNIX_SOCKET_PATH": "/tmp/redis.sock" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,4 +1,8 @@ | |||||||
| import { calcDifficultyAdjustment, DifficultyAdjustment } from '../../api/difficulty-adjustment'; | import { | ||||||
|  |   calcBitsDifference, | ||||||
|  |   calcDifficultyAdjustment, | ||||||
|  |   DifficultyAdjustment, | ||||||
|  | } from '../../api/difficulty-adjustment'; | ||||||
| 
 | 
 | ||||||
| describe('Mempool Difficulty Adjustment', () => { | describe('Mempool Difficulty Adjustment', () => { | ||||||
|   test('should calculate Difficulty Adjustments properly', () => { |   test('should calculate Difficulty Adjustments properly', () => { | ||||||
| @ -86,4 +90,46 @@ describe('Mempool Difficulty Adjustment', () => { | |||||||
|       expect(result).toStrictEqual(vector[1]); |       expect(result).toStrictEqual(vector[1]); | ||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
|  | 
 | ||||||
|  |   test('should calculate Difficulty change from bits fields of two blocks', () => { | ||||||
|  |     // Check same exponent + check min max for output
 | ||||||
|  |     expect(calcBitsDifference(0x1d000200, 0x1d000100)).toEqual(100); | ||||||
|  |     expect(calcBitsDifference(0x1d000400, 0x1d000100)).toEqual(300); | ||||||
|  |     expect(calcBitsDifference(0x1d000800, 0x1d000100)).toEqual(300); // Actually 700
 | ||||||
|  |     expect(calcBitsDifference(0x1d000100, 0x1d000200)).toEqual(-50); | ||||||
|  |     expect(calcBitsDifference(0x1d000100, 0x1d000400)).toEqual(-75); | ||||||
|  |     expect(calcBitsDifference(0x1d000100, 0x1d000800)).toEqual(-75); // Actually -87.5
 | ||||||
|  |     // Check new higher exponent
 | ||||||
|  |     expect(calcBitsDifference(0x1c000200, 0x1d000001)).toEqual(100); | ||||||
|  |     expect(calcBitsDifference(0x1c000400, 0x1d000001)).toEqual(300); | ||||||
|  |     expect(calcBitsDifference(0x1c000800, 0x1d000001)).toEqual(300); | ||||||
|  |     expect(calcBitsDifference(0x1c000100, 0x1d000002)).toEqual(-50); | ||||||
|  |     expect(calcBitsDifference(0x1c000100, 0x1d000004)).toEqual(-75); | ||||||
|  |     expect(calcBitsDifference(0x1c000100, 0x1d000008)).toEqual(-75); | ||||||
|  |     // Check new lower exponent
 | ||||||
|  |     expect(calcBitsDifference(0x1d000002, 0x1c000100)).toEqual(100); | ||||||
|  |     expect(calcBitsDifference(0x1d000004, 0x1c000100)).toEqual(300); | ||||||
|  |     expect(calcBitsDifference(0x1d000008, 0x1c000100)).toEqual(300); | ||||||
|  |     expect(calcBitsDifference(0x1d000001, 0x1c000200)).toEqual(-50); | ||||||
|  |     expect(calcBitsDifference(0x1d000001, 0x1c000400)).toEqual(-75); | ||||||
|  |     expect(calcBitsDifference(0x1d000001, 0x1c000800)).toEqual(-75); | ||||||
|  |     // Check error when exponents are too far apart
 | ||||||
|  |     expect(() => calcBitsDifference(0x1d000001, 0x1a000800)).toThrow( | ||||||
|  |       /Impossible exponent difference/ | ||||||
|  |     ); | ||||||
|  |     // Check invalid inputs
 | ||||||
|  |     expect(() => calcBitsDifference(0x7f000001, 0x1a000800)).toThrow( | ||||||
|  |       /Invalid bits/ | ||||||
|  |     ); | ||||||
|  |     expect(() => calcBitsDifference(0, 0x1a000800)).toThrow(/Invalid bits/); | ||||||
|  |     expect(() => calcBitsDifference(100.2783, 0x1a000800)).toThrow( | ||||||
|  |       /Invalid bits/ | ||||||
|  |     ); | ||||||
|  |     expect(() => calcBitsDifference(0x00800000, 0x1a000800)).toThrow( | ||||||
|  |       /Invalid bits/ | ||||||
|  |     ); | ||||||
|  |     expect(() => calcBitsDifference(0x1c000000, 0x1a000800)).toThrow( | ||||||
|  |       /Invalid bits/ | ||||||
|  |     ); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|  | |||||||
| @ -23,6 +23,7 @@ describe('Mempool Backend Config', () => { | |||||||
|         AUTOMATIC_BLOCK_REINDEXING: false, |         AUTOMATIC_BLOCK_REINDEXING: false, | ||||||
|         POLL_RATE_MS: 2000, |         POLL_RATE_MS: 2000, | ||||||
|         CACHE_DIR: './cache', |         CACHE_DIR: './cache', | ||||||
|  |         CACHE_ENABLED: true, | ||||||
|         CLEAR_PROTECTION_MINUTES: 20, |         CLEAR_PROTECTION_MINUTES: 20, | ||||||
|         RECOMMENDED_FEE_PERCENTILE: 50, |         RECOMMENDED_FEE_PERCENTILE: 50, | ||||||
|         BLOCK_WEIGHT_UNITS: 4000000, |         BLOCK_WEIGHT_UNITS: 4000000, | ||||||
| @ -127,6 +128,11 @@ describe('Mempool Backend Config', () => { | |||||||
|         AUDIT_START_HEIGHT: 774000, |         AUDIT_START_HEIGHT: 774000, | ||||||
|         SERVERS: [] |         SERVERS: [] | ||||||
|       }); |       }); | ||||||
|  | 
 | ||||||
|  |       expect(config.REDIS).toStrictEqual({ | ||||||
|  |         ENABLED: false, | ||||||
|  |         UNIX_SOCKET_PATH: '' | ||||||
|  |       }); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
| @ -160,6 +166,8 @@ describe('Mempool Backend Config', () => { | |||||||
|       expect(config.PRICE_DATA_SERVER).toStrictEqual(fixture.PRICE_DATA_SERVER); |       expect(config.PRICE_DATA_SERVER).toStrictEqual(fixture.PRICE_DATA_SERVER); | ||||||
| 
 | 
 | ||||||
|       expect(config.EXTERNAL_DATA_SERVER).toStrictEqual(fixture.EXTERNAL_DATA_SERVER); |       expect(config.EXTERNAL_DATA_SERVER).toStrictEqual(fixture.EXTERNAL_DATA_SERVER); | ||||||
|  | 
 | ||||||
|  |       expect(config.REDIS).toStrictEqual(fixture.REDIS); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
| @ -173,12 +181,12 @@ describe('Mempool Backend Config', () => { | |||||||
|           // 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'); |             console.log('skipping check for MEMPOOL_HTTP_PORT'); | ||||||
|             return; |             continue; | ||||||
|           } |           } | ||||||
|           switch (typeof value) { |           switch (typeof value) { | ||||||
|             case 'object': { |             case 'object': { | ||||||
|               if (Array.isArray(value)) { |               if (Array.isArray(value)) { | ||||||
|                 return; |                 continue; | ||||||
|               } else { |               } else { | ||||||
|                 parseJson(value, key); |                 parseJson(value, key); | ||||||
|               } |               } | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ class Audit { | |||||||
|     const matches: string[] = []; // present in both mined block and template
 |     const matches: string[] = []; // present in both mined block and template
 | ||||||
|     const added: string[] = []; // present in mined block, not in template
 |     const added: string[] = []; // present in mined block, not in template
 | ||||||
|     const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN
 |     const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN
 | ||||||
|     const fullrbf: string[] = []; // either missing or present, and part of a fullrbf replacement
 |     const rbf: string[] = []; // either missing or present, and either part of a full-rbf replacement, or a conflict with the mined block
 | ||||||
|     const isCensored = {}; // missing, without excuse
 |     const isCensored = {}; // missing, without excuse
 | ||||||
|     const isDisplaced = {}; |     const isDisplaced = {}; | ||||||
|     let displacedWeight = 0; |     let displacedWeight = 0; | ||||||
| @ -36,8 +36,9 @@ class Audit { | |||||||
|     // look for transactions that were expected in the template, but missing from the mined block
 |     // look for transactions that were expected in the template, but missing from the mined block
 | ||||||
|     for (const txid of projectedBlocks[0].transactionIds) { |     for (const txid of projectedBlocks[0].transactionIds) { | ||||||
|       if (!inBlock[txid]) { |       if (!inBlock[txid]) { | ||||||
|         if (rbfCache.isFullRbf(txid)) { |         // allow missing transactions which either belong to a full rbf tree, or conflict with any transaction in the mined block
 | ||||||
|           fullrbf.push(txid); |         if (rbfCache.has(txid) && (rbfCache.isFullRbf(txid) || rbfCache.anyInSameTree(txid, (tx) => inBlock[tx.txid]))) { | ||||||
|  |           rbf.push(txid); | ||||||
|         } else if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) { |         } else if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) { | ||||||
|           // tx is recent, may have reached the miner too late for inclusion
 |           // tx is recent, may have reached the miner too late for inclusion
 | ||||||
|           fresh.push(txid); |           fresh.push(txid); | ||||||
| @ -98,8 +99,8 @@ class Audit { | |||||||
|       if (inTemplate[tx.txid]) { |       if (inTemplate[tx.txid]) { | ||||||
|         matches.push(tx.txid); |         matches.push(tx.txid); | ||||||
|       } else { |       } else { | ||||||
|         if (rbfCache.isFullRbf(tx.txid)) { |         if (rbfCache.has(tx.txid)) { | ||||||
|           fullrbf.push(tx.txid); |           rbf.push(tx.txid); | ||||||
|         } else if (!isDisplaced[tx.txid]) { |         } else if (!isDisplaced[tx.txid]) { | ||||||
|           added.push(tx.txid); |           added.push(tx.txid); | ||||||
|         } |         } | ||||||
| @ -147,7 +148,7 @@ class Audit { | |||||||
|       added, |       added, | ||||||
|       fresh, |       fresh, | ||||||
|       sigop: [], |       sigop: [], | ||||||
|       fullrbf, |       fullrbf: rbf, | ||||||
|       score, |       score, | ||||||
|       similarity, |       similarity, | ||||||
|     }; |     }; | ||||||
|  | |||||||
| @ -3,10 +3,12 @@ import { IEsploraApi } from './esplora-api.interface'; | |||||||
| export interface AbstractBitcoinApi { | export interface AbstractBitcoinApi { | ||||||
|   $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>; |   $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>; | ||||||
|   $getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>; |   $getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>; | ||||||
|  |   $getMempoolTransactions(lastTxid: string); | ||||||
|   $getTransactionHex(txId: string): Promise<string>; |   $getTransactionHex(txId: string): Promise<string>; | ||||||
|   $getBlockHeightTip(): Promise<number>; |   $getBlockHeightTip(): Promise<number>; | ||||||
|   $getBlockHashTip(): Promise<string>; |   $getBlockHashTip(): Promise<string>; | ||||||
|   $getTxIdsForBlock(hash: string): Promise<string[]>; |   $getTxIdsForBlock(hash: string): Promise<string[]>; | ||||||
|  |   $getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]>; | ||||||
|   $getBlockHash(height: number): Promise<string>; |   $getBlockHash(height: number): Promise<string>; | ||||||
|   $getBlockHeader(hash: string): Promise<string>; |   $getBlockHeader(hash: string): Promise<string>; | ||||||
|   $getBlock(hash: string): Promise<IEsploraApi.Block>; |   $getBlock(hash: string): Promise<IEsploraApi.Block>; | ||||||
| @ -14,6 +16,8 @@ export interface AbstractBitcoinApi { | |||||||
|   $getAddress(address: string): Promise<IEsploraApi.Address>; |   $getAddress(address: string): Promise<IEsploraApi.Address>; | ||||||
|   $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>; |   $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>; | ||||||
|   $getAddressPrefix(prefix: string): string[]; |   $getAddressPrefix(prefix: string): string[]; | ||||||
|  |   $getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash>; | ||||||
|  |   $getScriptHashTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>; | ||||||
|   $sendRawTransaction(rawTransaction: string): Promise<string>; |   $sendRawTransaction(rawTransaction: string): Promise<string>; | ||||||
|   $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[]>; | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ import { IEsploraApi } from './esplora-api.interface'; | |||||||
| import blocks from '../blocks'; | import blocks from '../blocks'; | ||||||
| import mempool from '../mempool'; | import mempool from '../mempool'; | ||||||
| import { TransactionExtended } from '../../mempool.interfaces'; | import { TransactionExtended } from '../../mempool.interfaces'; | ||||||
|  | import transactionUtils from '../transaction-utils'; | ||||||
| 
 | 
 | ||||||
| class BitcoinApi implements AbstractBitcoinApi { | class BitcoinApi implements AbstractBitcoinApi { | ||||||
|   private rawMempoolCache: IBitcoinApi.RawMempool | null = null; |   private rawMempoolCache: IBitcoinApi.RawMempool | null = null; | ||||||
| @ -59,9 +60,20 @@ class BitcoinApi implements AbstractBitcoinApi { | |||||||
|       }); |       }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getTransactionHex(txId: string): Promise<string> { |   $getMempoolTransactions(lastTxid: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|     return this.$getRawTransaction(txId, true) |     return Promise.resolve([]); | ||||||
|       .then((tx) => tx.hex || ''); |   } | ||||||
|  | 
 | ||||||
|  |   async $getTransactionHex(txId: string): Promise<string> { | ||||||
|  |     const txInMempool = mempool.getMempool()[txId]; | ||||||
|  |     if (txInMempool && txInMempool.hex) { | ||||||
|  |       return txInMempool.hex; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return this.bitcoindClient.getRawTransaction(txId, true) | ||||||
|  |       .then((transaction: IBitcoinApi.Transaction) => { | ||||||
|  |         return transaction.hex; | ||||||
|  |       }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getBlockHeightTip(): Promise<number> { |   $getBlockHeightTip(): Promise<number> { | ||||||
| @ -77,6 +89,10 @@ class BitcoinApi implements AbstractBitcoinApi { | |||||||
|       .then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx); |       .then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   $getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|  |     throw new Error('Method getTxsForBlock not supported by the Bitcoin RPC API.'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   $getRawBlock(hash: string): Promise<Buffer> { |   $getRawBlock(hash: string): Promise<Buffer> { | ||||||
|     return this.bitcoindClient.getBlock(hash, 0) |     return this.bitcoindClient.getBlock(hash, 0) | ||||||
|       .then((raw: string) => Buffer.from(raw, "hex")); |       .then((raw: string) => Buffer.from(raw, "hex")); | ||||||
| @ -108,6 +124,14 @@ class BitcoinApi implements AbstractBitcoinApi { | |||||||
|     throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.'); |     throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   $getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> { | ||||||
|  |     throw new Error('Method getScriptHash not supported by the Bitcoin RPC API.'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   $getScriptHashTransactions(scripthash: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|  |     throw new Error('Method getScriptHashTransactions not supported by the Bitcoin RPC API.'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> { |   $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> { | ||||||
|     return this.bitcoindClient.getRawMemPool(); |     return this.bitcoindClient.getRawMemPool(); | ||||||
|   } |   } | ||||||
| @ -193,7 +217,7 @@ class BitcoinApi implements AbstractBitcoinApi { | |||||||
|         scriptpubkey: vout.scriptPubKey.hex, |         scriptpubkey: vout.scriptPubKey.hex, | ||||||
|         scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address |         scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address | ||||||
|           : vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '', |           : vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '', | ||||||
|         scriptpubkey_asm: vout.scriptPubKey.asm ? this.convertScriptSigAsm(vout.scriptPubKey.hex) : '', |         scriptpubkey_asm: vout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(vout.scriptPubKey.hex) : '', | ||||||
|         scriptpubkey_type: this.translateScriptPubKeyType(vout.scriptPubKey.type), |         scriptpubkey_type: this.translateScriptPubKeyType(vout.scriptPubKey.type), | ||||||
|       }; |       }; | ||||||
|     }); |     }); | ||||||
| @ -203,7 +227,7 @@ class BitcoinApi implements AbstractBitcoinApi { | |||||||
|         is_coinbase: !!vin.coinbase, |         is_coinbase: !!vin.coinbase, | ||||||
|         prevout: null, |         prevout: null, | ||||||
|         scriptsig: vin.scriptSig && vin.scriptSig.hex || vin.coinbase || '', |         scriptsig: vin.scriptSig && vin.scriptSig.hex || vin.coinbase || '', | ||||||
|         scriptsig_asm: vin.scriptSig && this.convertScriptSigAsm(vin.scriptSig.hex) || '', |         scriptsig_asm: vin.scriptSig && transactionUtils.convertScriptSigAsm(vin.scriptSig.hex) || '', | ||||||
|         sequence: vin.sequence, |         sequence: vin.sequence, | ||||||
|         txid: vin.txid || '', |         txid: vin.txid || '', | ||||||
|         vout: vin.vout || 0, |         vout: vin.vout || 0, | ||||||
| @ -275,7 +299,7 @@ class BitcoinApi implements AbstractBitcoinApi { | |||||||
|       } |       } | ||||||
|       const innerTx = await this.$getRawTransaction(vin.txid, false, false); |       const innerTx = await this.$getRawTransaction(vin.txid, false, false); | ||||||
|       vin.prevout = innerTx.vout[vin.vout]; |       vin.prevout = innerTx.vout[vin.vout]; | ||||||
|       this.addInnerScriptsToVin(vin); |       transactionUtils.addInnerScriptsToVin(vin); | ||||||
|     } |     } | ||||||
|     return transaction; |     return transaction; | ||||||
|   } |   } | ||||||
| @ -314,7 +338,7 @@ class BitcoinApi implements AbstractBitcoinApi { | |||||||
|       } |       } | ||||||
|       const innerTx = await this.$getRawTransaction(transaction.vin[i].txid, false, false); |       const innerTx = await this.$getRawTransaction(transaction.vin[i].txid, false, false); | ||||||
|       transaction.vin[i].prevout = innerTx.vout[transaction.vin[i].vout]; |       transaction.vin[i].prevout = innerTx.vout[transaction.vin[i].vout]; | ||||||
|       this.addInnerScriptsToVin(transaction.vin[i]); |       transactionUtils.addInnerScriptsToVin(transaction.vin[i]); | ||||||
|       totalIn += innerTx.vout[transaction.vin[i].vout].value; |       totalIn += innerTx.vout[transaction.vin[i].vout].value; | ||||||
|     } |     } | ||||||
|     if (lazyPrevouts && transaction.vin.length > 12) { |     if (lazyPrevouts && transaction.vin.length > 12) { | ||||||
| @ -326,122 +350,6 @@ class BitcoinApi implements AbstractBitcoinApi { | |||||||
|     return transaction; |     return transaction; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private convertScriptSigAsm(hex: string): string { |  | ||||||
|     const buf = Buffer.from(hex, 'hex'); |  | ||||||
| 
 |  | ||||||
|     const b: string[] = []; |  | ||||||
| 
 |  | ||||||
|     let i = 0; |  | ||||||
|     while (i < buf.length) { |  | ||||||
|       const op = buf[i]; |  | ||||||
|       if (op >= 0x01 && op <= 0x4e) { |  | ||||||
|         i++; |  | ||||||
|         let push: number; |  | ||||||
|         if (op === 0x4c) { |  | ||||||
|           push = buf.readUInt8(i); |  | ||||||
|           b.push('OP_PUSHDATA1'); |  | ||||||
|           i += 1; |  | ||||||
|         } else if (op === 0x4d) { |  | ||||||
|           push = buf.readUInt16LE(i); |  | ||||||
|           b.push('OP_PUSHDATA2'); |  | ||||||
|           i += 2; |  | ||||||
|         } else if (op === 0x4e) { |  | ||||||
|           push = buf.readUInt32LE(i); |  | ||||||
|           b.push('OP_PUSHDATA4'); |  | ||||||
|           i += 4; |  | ||||||
|         } else { |  | ||||||
|           push = op; |  | ||||||
|           b.push('OP_PUSHBYTES_' + push); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         const data = buf.slice(i, i + push); |  | ||||||
|         if (data.length !== push) { |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         b.push(data.toString('hex')); |  | ||||||
|         i += data.length; |  | ||||||
|       } else { |  | ||||||
|         if (op === 0x00) { |  | ||||||
|           b.push('OP_0'); |  | ||||||
|         } else if (op === 0x4f) { |  | ||||||
|           b.push('OP_PUSHNUM_NEG1'); |  | ||||||
|         } else if (op === 0xb1) { |  | ||||||
|           b.push('OP_CLTV'); |  | ||||||
|         } else if (op === 0xb2) { |  | ||||||
|           b.push('OP_CSV'); |  | ||||||
|         } else if (op === 0xba) { |  | ||||||
|           b.push('OP_CHECKSIGADD'); |  | ||||||
|         } else { |  | ||||||
|           const opcode = bitcoinjs.script.toASM([ op ]); |  | ||||||
|           if (opcode && op < 0xfd) { |  | ||||||
|             if (/^OP_(\d+)$/.test(opcode)) { |  | ||||||
|               b.push(opcode.replace(/^OP_(\d+)$/, 'OP_PUSHNUM_$1')); |  | ||||||
|             } else { |  | ||||||
|               b.push(opcode); |  | ||||||
|             } |  | ||||||
|           } else { |  | ||||||
|             b.push('OP_RETURN_' + op); |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|         i += 1; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return b.join(' '); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private addInnerScriptsToVin(vin: IEsploraApi.Vin): void { |  | ||||||
|     if (!vin.prevout) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (vin.prevout.scriptpubkey_type === 'p2sh') { |  | ||||||
|       const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0]; |  | ||||||
|       vin.inner_redeemscript_asm = this.convertScriptSigAsm(redeemScript); |  | ||||||
|       if (vin.witness && vin.witness.length > 2) { |  | ||||||
|         const witnessScript = vin.witness[vin.witness.length - 1]; |  | ||||||
|         vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) { |  | ||||||
|       const witnessScript = vin.witness[vin.witness.length - 1]; |  | ||||||
|       vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) { |  | ||||||
|       const witnessScript = this.witnessToP2TRScript(vin.witness); |  | ||||||
|       if (witnessScript !== null) { |  | ||||||
|         vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |  | ||||||
|    * This function must only be called when we know the witness we are parsing |  | ||||||
|    * is a taproot witness. |  | ||||||
|    * @param witness An array of hex strings that represents the witness stack of |  | ||||||
|    *                the input. |  | ||||||
|    * @returns null if the witness is not a script spend, and the hex string of |  | ||||||
|    *          the script item if it is a script spend. |  | ||||||
|    */ |  | ||||||
|   private witnessToP2TRScript(witness: string[]): string | null { |  | ||||||
|     if (witness.length < 2) return null; |  | ||||||
|     // Note: see BIP341 for parsing details of witness stack
 |  | ||||||
| 
 |  | ||||||
|     // If there are at least two witness elements, and the first byte of the
 |  | ||||||
|     // last element is 0x50, this last element is called annex a and
 |  | ||||||
|     // is removed from the witness stack.
 |  | ||||||
|     const hasAnnex = witness[witness.length - 1].substring(0, 2) === '50'; |  | ||||||
|     // If there are at least two witness elements left, script path spending is used.
 |  | ||||||
|     // Call the second-to-last stack element s, the script.
 |  | ||||||
|     // (Note: this phrasing from BIP341 assumes we've *removed* the annex from the stack)
 |  | ||||||
|     if (hasAnnex && witness.length < 3) return null; |  | ||||||
|     const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2; |  | ||||||
|     return witness[positionOfScript]; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default BitcoinApi; | export default BitcoinApi; | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ import websocketHandler from '../websocket-handler'; | |||||||
| import mempool from '../mempool'; | import mempool from '../mempool'; | ||||||
| import feeApi from '../fee-api'; | import feeApi from '../fee-api'; | ||||||
| import mempoolBlocks from '../mempool-blocks'; | import mempoolBlocks from '../mempool-blocks'; | ||||||
| import bitcoinApi, { bitcoinCoreApi } from './bitcoin-api-factory'; | import bitcoinApi from './bitcoin-api-factory'; | ||||||
| import { Common } from '../common'; | import { Common } from '../common'; | ||||||
| import backendInfo from '../backend-info'; | import backendInfo from '../backend-info'; | ||||||
| import transactionUtils from '../transaction-utils'; | import transactionUtils from '../transaction-utils'; | ||||||
| @ -121,6 +121,8 @@ class BitcoinRoutes { | |||||||
|           .get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', this.getBlockHeight) |           .get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', this.getBlockHeight) | ||||||
|           .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress) |           .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress) | ||||||
|           .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions) |           .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions) | ||||||
|  |           .get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash', this.getScriptHash) | ||||||
|  |           .get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash/txs', this.getScriptHashTransactions) | ||||||
|           .get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', this.getAddressPrefix) |           .get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', this.getAddressPrefix) | ||||||
|           ; |           ; | ||||||
|       } |       } | ||||||
| @ -481,7 +483,7 @@ class BitcoinRoutes { | |||||||
|           returnBlocks.push(localBlock); |           returnBlocks.push(localBlock); | ||||||
|           nextHash = localBlock.previousblockhash; |           nextHash = localBlock.previousblockhash; | ||||||
|         } else { |         } else { | ||||||
|           const block = await bitcoinCoreApi.$getBlock(nextHash); |           const block = await bitcoinApi.$getBlock(nextHash); | ||||||
|           returnBlocks.push(block); |           returnBlocks.push(block); | ||||||
|           nextHash = block.previousblockhash; |           nextHash = block.previousblockhash; | ||||||
|         } |         } | ||||||
| @ -567,6 +569,45 @@ class BitcoinRoutes { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   private async getScriptHash(req: Request, res: Response) { | ||||||
|  |     if (config.MEMPOOL.BACKEND === 'none') { | ||||||
|  |       res.status(405).send('Address lookups cannot be used with bitcoind as backend.'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |       const addressData = await bitcoinApi.$getScriptHash(req.params.scripthash); | ||||||
|  |       res.json(addressData); | ||||||
|  |     } catch (e) { | ||||||
|  |       if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { | ||||||
|  |         return res.status(413).send(e instanceof Error ? e.message : e); | ||||||
|  |       } | ||||||
|  |       res.status(500).send(e instanceof Error ? e.message : e); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private async getScriptHashTransactions(req: Request, res: Response): Promise<void> { | ||||||
|  |     if (config.MEMPOOL.BACKEND === 'none') { | ||||||
|  |       res.status(405).send('Address lookups cannot be used with bitcoind as backend.'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |       let lastTxId: string = ''; | ||||||
|  |       if (req.query.after_txid && typeof req.query.after_txid === 'string') { | ||||||
|  |         lastTxId = req.query.after_txid; | ||||||
|  |       } | ||||||
|  |       const transactions = await bitcoinApi.$getScriptHashTransactions(req.params.scripthash, lastTxId); | ||||||
|  |       res.json(transactions); | ||||||
|  |     } catch (e) { | ||||||
|  |       if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { | ||||||
|  |         res.status(413).send(e instanceof Error ? e.message : e); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       res.status(500).send(e instanceof Error ? e.message : e); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   private async getAddressPrefix(req: Request, res: Response) { |   private async getAddressPrefix(req: Request, res: Response) { | ||||||
|     try { |     try { | ||||||
|       const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); |       const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); | ||||||
|  | |||||||
| @ -126,6 +126,77 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async $getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> { | ||||||
|  |     try { | ||||||
|  |       const balance = await this.electrumClient.blockchainScripthash_getBalance(scripthash); | ||||||
|  |       let history = memoryCache.get<IElectrumApi.ScriptHashHistory[]>('Scripthash_getHistory', scripthash); | ||||||
|  |       if (!history) { | ||||||
|  |         history = await this.electrumClient.blockchainScripthash_getHistory(scripthash); | ||||||
|  |         memoryCache.set('Scripthash_getHistory', scripthash, history, 2); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       const unconfirmed = history ? history.filter((h) => h.fee).length : 0; | ||||||
|  | 
 | ||||||
|  |       return { | ||||||
|  |         'scripthash': scripthash, | ||||||
|  |         'chain_stats': { | ||||||
|  |           'funded_txo_count': 0, | ||||||
|  |           'funded_txo_sum': balance.confirmed ? balance.confirmed : 0, | ||||||
|  |           'spent_txo_count': 0, | ||||||
|  |           'spent_txo_sum': balance.confirmed < 0 ? balance.confirmed : 0, | ||||||
|  |           'tx_count': (history?.length || 0) - unconfirmed, | ||||||
|  |         }, | ||||||
|  |         'mempool_stats': { | ||||||
|  |           'funded_txo_count': 0, | ||||||
|  |           'funded_txo_sum': balance.unconfirmed > 0 ? balance.unconfirmed : 0, | ||||||
|  |           'spent_txo_count': 0, | ||||||
|  |           'spent_txo_sum': balance.unconfirmed < 0 ? -balance.unconfirmed : 0, | ||||||
|  |           'tx_count': unconfirmed, | ||||||
|  |         }, | ||||||
|  |         'electrum': true, | ||||||
|  |       }; | ||||||
|  |     } catch (e: any) { | ||||||
|  |       throw new Error(typeof e === 'string' ? e : e && e.message || e); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $getScriptHashTransactions(scripthash: string, lastSeenTxId?: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|  |     try { | ||||||
|  |       loadingIndicators.setProgress('address-' + scripthash, 0); | ||||||
|  | 
 | ||||||
|  |       const transactions: IEsploraApi.Transaction[] = []; | ||||||
|  |       let history = memoryCache.get<IElectrumApi.ScriptHashHistory[]>('Scripthash_getHistory', scripthash); | ||||||
|  |       if (!history) { | ||||||
|  |         history = await this.electrumClient.blockchainScripthash_getHistory(scripthash); | ||||||
|  |         memoryCache.set('Scripthash_getHistory', scripthash, history, 2); | ||||||
|  |       } | ||||||
|  |       if (!history) { | ||||||
|  |         throw new Error('failed to get scripthash history'); | ||||||
|  |       } | ||||||
|  |       history.sort((a, b) => (b.height || 9999999) - (a.height || 9999999)); | ||||||
|  | 
 | ||||||
|  |       let startingIndex = 0; | ||||||
|  |       if (lastSeenTxId) { | ||||||
|  |         const pos = history.findIndex((historicalTx) => historicalTx.tx_hash === lastSeenTxId); | ||||||
|  |         if (pos) { | ||||||
|  |           startingIndex = pos + 1; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       const endIndex = Math.min(startingIndex + 10, history.length); | ||||||
|  | 
 | ||||||
|  |       for (let i = startingIndex; i < endIndex; i++) { | ||||||
|  |         const tx = await this.$getRawTransaction(history[i].tx_hash, false, true); | ||||||
|  |         transactions.push(tx); | ||||||
|  |         loadingIndicators.setProgress('address-' + scripthash, (i + 1) / endIndex * 100); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       return transactions; | ||||||
|  |     } catch (e: any) { | ||||||
|  |       loadingIndicators.setProgress('address-' + scripthash, 100); | ||||||
|  |       throw new Error(typeof e === 'string' ? e : e && e.message || e); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   private $getScriptHashBalance(scriptHash: string): Promise<IElectrumApi.ScriptHashBalance> { |   private $getScriptHashBalance(scriptHash: string): Promise<IElectrumApi.ScriptHashBalance> { | ||||||
|     return this.electrumClient.blockchainScripthash_getBalance(this.encodeScriptHash(scriptHash)); |     return this.electrumClient.blockchainScripthash_getBalance(this.encodeScriptHash(scriptHash)); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -99,6 +99,13 @@ export namespace IEsploraApi { | |||||||
|     electrum?: boolean; |     electrum?: boolean; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   export interface ScriptHash { | ||||||
|  |     scripthash: string; | ||||||
|  |     chain_stats: ChainStats; | ||||||
|  |     mempool_stats: MempoolStats; | ||||||
|  |     electrum?: boolean; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   export interface ChainStats { |   export interface ChainStats { | ||||||
|     funded_txo_count: number; |     funded_txo_count: number; | ||||||
|     funded_txo_sum: number; |     funded_txo_sum: number; | ||||||
|  | |||||||
| @ -69,6 +69,10 @@ class ElectrsApi implements AbstractBitcoinApi { | |||||||
|     return this.$queryWrapper<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId); |     return this.$queryWrapper<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async $getMempoolTransactions(lastSeenTxid?: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|  |     return this.$queryWrapper<IEsploraApi.Transaction[]>(config.ESPLORA.REST_API_URL + '/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.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex'); | ||||||
|   } |   } | ||||||
| @ -85,6 +89,10 @@ class ElectrsApi implements AbstractBitcoinApi { | |||||||
|     return this.$queryWrapper<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids'); |     return this.$queryWrapper<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   $getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|  |     return this.$queryWrapper<IEsploraApi.Transaction[]>(config.ESPLORA.REST_API_URL + '/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.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height); | ||||||
|   } |   } | ||||||
| @ -110,6 +118,14 @@ class ElectrsApi implements AbstractBitcoinApi { | |||||||
|     throw new Error('Method getAddressTransactions not implemented.'); |     throw new Error('Method getAddressTransactions not implemented.'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   $getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> { | ||||||
|  |     throw new Error('Method getScriptHash not implemented.'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   $getScriptHashTransactions(scripthash: string, txId?: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|  |     throw new Error('Method getScriptHashTransactions not implemented.'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   $getAddressPrefix(prefix: string): string[] { |   $getAddressPrefix(prefix: string): string[] { | ||||||
|     throw new Error('Method not implemented.'); |     throw new Error('Method not implemented.'); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -26,12 +26,15 @@ import PricesRepository from '../repositories/PricesRepository'; | |||||||
| import priceUpdater from '../tasks/price-updater'; | import priceUpdater from '../tasks/price-updater'; | ||||||
| import chainTips from './chain-tips'; | import chainTips from './chain-tips'; | ||||||
| import websocketHandler from './websocket-handler'; | import websocketHandler from './websocket-handler'; | ||||||
|  | import redisCache from './redis-cache'; | ||||||
|  | import rbfCache from './rbf-cache'; | ||||||
|  | import { calcBitsDifference } from './difficulty-adjustment'; | ||||||
| 
 | 
 | ||||||
| class Blocks { | class Blocks { | ||||||
|   private blocks: BlockExtended[] = []; |   private blocks: BlockExtended[] = []; | ||||||
|   private blockSummaries: BlockSummary[] = []; |   private blockSummaries: BlockSummary[] = []; | ||||||
|   private currentBlockHeight = 0; |   private currentBlockHeight = 0; | ||||||
|   private currentDifficulty = 0; |   private currentBits = 0; | ||||||
|   private lastDifficultyAdjustmentTime = 0; |   private lastDifficultyAdjustmentTime = 0; | ||||||
|   private previousDifficultyRetarget = 0; |   private previousDifficultyRetarget = 0; | ||||||
|   private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; |   private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; | ||||||
| @ -70,6 +73,9 @@ class Blocks { | |||||||
|    * @param blockHash |    * @param blockHash | ||||||
|    * @param blockHeight |    * @param blockHeight | ||||||
|    * @param onlyCoinbase - Set to true if you only need the coinbase transaction |    * @param onlyCoinbase - Set to true if you only need the coinbase transaction | ||||||
|  |    * @param txIds - optional ordered list of transaction ids if already known | ||||||
|  |    * @param quiet - don't print non-essential logs | ||||||
|  |    * @param addMempoolData - calculate sigops etc | ||||||
|    * @returns Promise<TransactionExtended[]> |    * @returns Promise<TransactionExtended[]> | ||||||
|    */ |    */ | ||||||
|   private async $getTransactionsExtended( |   private async $getTransactionsExtended( | ||||||
| @ -80,62 +86,98 @@ class Blocks { | |||||||
|     quiet: boolean = false, |     quiet: boolean = false, | ||||||
|     addMempoolData: boolean = false, |     addMempoolData: boolean = false, | ||||||
|   ): Promise<TransactionExtended[]> { |   ): Promise<TransactionExtended[]> { | ||||||
|     const transactions: TransactionExtended[] = []; |     const isEsplora = config.MEMPOOL.BACKEND === 'esplora'; | ||||||
|  |     const transactionMap: { [txid: string]: TransactionExtended } = {}; | ||||||
|  | 
 | ||||||
|     if (!txIds) { |     if (!txIds) { | ||||||
|       txIds = await bitcoinApi.$getTxIdsForBlock(blockHash); |       txIds = await bitcoinApi.$getTxIdsForBlock(blockHash); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const mempool = memPool.getMempool(); |     const mempool = memPool.getMempool(); | ||||||
|     let transactionsFound = 0; |     let foundInMempool = 0; | ||||||
|     let transactionsFetched = 0; |     let totalFound = 0; | ||||||
| 
 | 
 | ||||||
|     for (let i = 0; i < txIds.length; i++) { |     // Copy existing transactions from the mempool
 | ||||||
|       if (mempool[txIds[i]]) { |     if (!onlyCoinbase) { | ||||||
|         // We update blocks before the mempool (index.ts), therefore we can
 |       for (const txid of txIds) { | ||||||
|         // optimize here by directly fetching txs in the "outdated" mempool
 |         if (mempool[txid]) { | ||||||
|         transactions.push(mempool[txIds[i]]); |           transactionMap[txid] = mempool[txid]; | ||||||
|         transactionsFound++; |           foundInMempool++; | ||||||
|       } else if (config.MEMPOOL.BACKEND === 'esplora' || !memPool.hasPriority() || i === 0) { |           totalFound++; | ||||||
|         // Otherwise we fetch the tx data through backend services (esplora, electrum, core rpc...)
 |  | ||||||
|         if (!quiet && (i % (Math.round((txIds.length) / 10)) === 0 || i + 1 === txIds.length)) { // Avoid log spam
 |  | ||||||
|           logger.debug(`Indexing tx ${i + 1} of ${txIds.length} in block #${blockHeight}`); |  | ||||||
|         } |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (onlyCoinbase) { | ||||||
|       try { |       try { | ||||||
|           const tx = await transactionUtils.$getTransactionExtended(txIds[i], false, false, false, addMempoolData); |         const coinbase = await transactionUtils.$getTransactionExtendedRetry(txIds[0], false, false, false, addMempoolData); | ||||||
|           transactions.push(tx); |         if (coinbase && coinbase.vin[0].is_coinbase) { | ||||||
|           transactionsFetched++; |           return [coinbase]; | ||||||
|         } catch (e) { |  | ||||||
|           try { |  | ||||||
|             if (config.MEMPOOL.BACKEND === 'esplora') { |  | ||||||
|               // Try again with core
 |  | ||||||
|               const tx = await transactionUtils.$getTransactionExtended(txIds[i], false, false, true, addMempoolData); |  | ||||||
|               transactions.push(tx); |  | ||||||
|               transactionsFetched++; |  | ||||||
|         } else { |         } else { | ||||||
|               throw e; |           const msg = `Expected a coinbase tx, but the backend API returned something else`; | ||||||
|             } |  | ||||||
|           } catch (e) { |  | ||||||
|             if (i === 0) { |  | ||||||
|               const msg = `Cannot fetch coinbase tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e);  |  | ||||||
|           logger.err(msg); |           logger.err(msg); | ||||||
|           throw new Error(msg); |           throw new Error(msg); | ||||||
|             } else { |  | ||||||
|               logger.err(`Cannot fetch tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e)); |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|  |       } catch (e) { | ||||||
|  |         const msg = `Cannot fetch coinbase tx ${txIds[0]}. Reason: ` + (e instanceof Error ? e.message : e); | ||||||
|  |         logger.err(msg); | ||||||
|  |         throw new Error(msg); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|       if (onlyCoinbase === true) { |     // Fetch remaining txs in bulk
 | ||||||
|         break; // Fetch the first transaction and exit
 |     if (isEsplora && (txIds.length - totalFound > 500)) { | ||||||
|  |       try { | ||||||
|  |         const rawTransactions = await bitcoinApi.$getTxsForBlock(blockHash); | ||||||
|  |         for (const tx of rawTransactions) { | ||||||
|  |           if (!transactionMap[tx.txid]) { | ||||||
|  |             transactionMap[tx.txid] = addMempoolData ? transactionUtils.extendMempoolTransaction(tx) : transactionUtils.extendTransaction(tx); | ||||||
|  |             totalFound++; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } catch (e) { | ||||||
|  |         logger.err(`Cannot fetch bulk txs for block ${blockHash}. Reason: ` + (e instanceof Error ? e.message : e)); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Fetch remaining txs individually
 | ||||||
|  |     for (const txid of txIds.filter(txid => !transactionMap[txid])) { | ||||||
|  |       if (!quiet && (totalFound % (Math.round((txIds.length) / 10)) === 0 || totalFound + 1 === txIds.length)) { // Avoid log spam
 | ||||||
|  |         logger.debug(`Indexing tx ${totalFound + 1} of ${txIds.length} in block #${blockHeight}`); | ||||||
|  |       } | ||||||
|  |       try { | ||||||
|  |         const tx = await transactionUtils.$getTransactionExtendedRetry(txid, false, false, false, addMempoolData); | ||||||
|  |         transactionMap[txid] = tx; | ||||||
|  |         totalFound++; | ||||||
|  |       } catch (e) { | ||||||
|  |         const msg = `Cannot fetch tx ${txid}. Reason: ` + (e instanceof Error ? e.message : e); | ||||||
|  |         logger.err(msg); | ||||||
|  |         throw new Error(msg); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (!quiet) { |     if (!quiet) { | ||||||
|       logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`); |       logger.debug(`${foundInMempool} of ${txIds.length} found in mempool. ${totalFound - foundInMempool} fetched through backend service.`); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return transactions; |     // Require the first transaction to be a coinbase
 | ||||||
|  |     const coinbase = transactionMap[txIds[0]]; | ||||||
|  |     if (!coinbase || !coinbase.vin[0].is_coinbase) { | ||||||
|  |       const msg = `Expected first tx in a block to be a coinbase, but found something else`; | ||||||
|  |       logger.err(msg); | ||||||
|  |       throw new Error(msg); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Require all transactions to be present
 | ||||||
|  |     // (we should have thrown an error already if a tx request failed)
 | ||||||
|  |     if (txIds.some(txid => !transactionMap[txid])) { | ||||||
|  |       const msg = `Failed to fetch ${txIds.length - totalFound} transactions from block`; | ||||||
|  |       logger.err(msg); | ||||||
|  |       throw new Error(msg); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Return list of transactions, preserving block order
 | ||||||
|  |     return txIds.map(txid => transactionMap[txid]); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -171,7 +213,9 @@ class Blocks { | |||||||
| 
 | 
 | ||||||
|   private convertLiquidFees(block: IBitcoinApi.VerboseBlock): IBitcoinApi.VerboseBlock { |   private convertLiquidFees(block: IBitcoinApi.VerboseBlock): IBitcoinApi.VerboseBlock { | ||||||
|     block.tx.forEach(tx => { |     block.tx.forEach(tx => { | ||||||
|  |       if (!isFinite(Number(tx.fee))) { | ||||||
|         tx.fee = Object.values(tx.fee || {}).reduce((total, output) => total + output, 0); |         tx.fee = Object.values(tx.fee || {}).reduce((total, output) => total + output, 0); | ||||||
|  |       } | ||||||
|     }); |     }); | ||||||
|     return block; |     return block; | ||||||
|   } |   } | ||||||
| @ -376,8 +420,8 @@ class Blocks { | |||||||
|       let newlyIndexed = 0; |       let newlyIndexed = 0; | ||||||
|       let totalIndexed = indexedBlockSummariesHashesArray.length; |       let totalIndexed = indexedBlockSummariesHashesArray.length; | ||||||
|       let indexedThisRun = 0; |       let indexedThisRun = 0; | ||||||
|       let timer = new Date().getTime() / 1000; |       let timer = Date.now() / 1000; | ||||||
|       const startedAt = new Date().getTime() / 1000; |       const startedAt = Date.now() / 1000; | ||||||
| 
 | 
 | ||||||
|       for (const block of indexedBlocks) { |       for (const block of indexedBlocks) { | ||||||
|         if (indexedBlockSummariesHashes[block.hash] === true) { |         if (indexedBlockSummariesHashes[block.hash] === true) { | ||||||
| @ -385,17 +429,24 @@ class Blocks { | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // Logging
 |         // Logging
 | ||||||
|         const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); |         const elapsedSeconds = (Date.now() / 1000) - timer; | ||||||
|         if (elapsedSeconds > 5) { |         if (elapsedSeconds > 5) { | ||||||
|           const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); |           const runningFor = (Date.now() / 1000) - startedAt; | ||||||
|           const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds); |           const blockPerSeconds = indexedThisRun / elapsedSeconds; | ||||||
|           const progress = Math.round(totalIndexed / indexedBlocks.length * 10000) / 100; |           const progress = Math.round(totalIndexed / indexedBlocks.length * 10000) / 100; | ||||||
|           logger.debug(`Indexing block summary for #${block.height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexedBlocks.length} (${progress}%) | elapsed: ${runningFor} seconds`, logger.tags.mining); |           logger.debug(`Indexing block summary for #${block.height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexedBlocks.length} (${progress}%) | elapsed: ${runningFor.toFixed(2)} seconds`, logger.tags.mining); | ||||||
|           timer = new Date().getTime() / 1000; |           timer = Date.now() / 1000; | ||||||
|           indexedThisRun = 0; |           indexedThisRun = 0; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  |         if (config.MEMPOOL.BACKEND === 'esplora') { | ||||||
|  |           const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendTransaction(tx)); | ||||||
|  |           const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs); | ||||||
|  |           await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary
 | ||||||
|  |         } else { | ||||||
|           await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary
 |           await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary
 | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
|         // Logging
 |         // Logging
 | ||||||
|         indexedThisRun++; |         indexedThisRun++; | ||||||
| @ -434,18 +485,18 @@ class Blocks { | |||||||
|       // Logging
 |       // Logging
 | ||||||
|       let count = 0; |       let count = 0; | ||||||
|       let countThisRun = 0; |       let countThisRun = 0; | ||||||
|       let timer = new Date().getTime() / 1000; |       let timer = Date.now() / 1000; | ||||||
|       const startedAt = new Date().getTime() / 1000; |       const startedAt = Date.now() / 1000; | ||||||
|       for (const height of unindexedBlockHeights) { |       for (const height of unindexedBlockHeights) { | ||||||
|         // Logging
 |         // Logging
 | ||||||
|         const hash = await bitcoinApi.$getBlockHash(height); |         const hash = await bitcoinApi.$getBlockHash(height); | ||||||
|         const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer); |         const elapsedSeconds = (Date.now() / 1000) - timer; | ||||||
|         if (elapsedSeconds > 5) { |         if (elapsedSeconds > 5) { | ||||||
|           const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); |           const runningFor = (Date.now() / 1000) - startedAt; | ||||||
|           const blockPerSeconds = (countThisRun / elapsedSeconds); |           const blockPerSeconds = countThisRun / elapsedSeconds; | ||||||
|           const progress = Math.round(count / unindexedBlockHeights.length * 10000) / 100; |           const progress = Math.round(count / unindexedBlockHeights.length * 10000) / 100; | ||||||
|           logger.debug(`Indexing cpfp clusters for #${height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${count}/${unindexedBlockHeights.length} (${progress}%) | elapsed: ${runningFor} seconds`); |           logger.debug(`Indexing cpfp clusters for #${height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${count}/${unindexedBlockHeights.length} (${progress}%) | elapsed: ${runningFor.toFixed(2)} seconds`); | ||||||
|           timer = new Date().getTime() / 1000; |           timer = Date.now() / 1000; | ||||||
|           countThisRun = 0; |           countThisRun = 0; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
| @ -524,8 +575,8 @@ class Blocks { | |||||||
|       let totalIndexed = await blocksRepository.$blockCountBetweenHeight(currentBlockHeight, lastBlockToIndex); |       let totalIndexed = await blocksRepository.$blockCountBetweenHeight(currentBlockHeight, lastBlockToIndex); | ||||||
|       let indexedThisRun = 0; |       let indexedThisRun = 0; | ||||||
|       let newlyIndexed = 0; |       let newlyIndexed = 0; | ||||||
|       const startedAt = new Date().getTime() / 1000; |       const startedAt = Date.now() / 1000; | ||||||
|       let timer = new Date().getTime() / 1000; |       let timer = Date.now() / 1000; | ||||||
| 
 | 
 | ||||||
|       while (currentBlockHeight >= lastBlockToIndex) { |       while (currentBlockHeight >= lastBlockToIndex) { | ||||||
|         const endBlock = Math.max(0, lastBlockToIndex, currentBlockHeight - chunkSize + 1); |         const endBlock = Math.max(0, lastBlockToIndex, currentBlockHeight - chunkSize + 1); | ||||||
| @ -545,18 +596,18 @@ class Blocks { | |||||||
|           } |           } | ||||||
|           ++indexedThisRun; |           ++indexedThisRun; | ||||||
|           ++totalIndexed; |           ++totalIndexed; | ||||||
|           const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer); |           const elapsedSeconds = (Date.now() / 1000) - timer; | ||||||
|           if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) { |           if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) { | ||||||
|             const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); |             const runningFor = (Date.now() / 1000) - startedAt; | ||||||
|             const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds); |             const blockPerSeconds = indexedThisRun / elapsedSeconds; | ||||||
|             const progress = Math.round(totalIndexed / indexingBlockAmount * 10000) / 100; |             const progress = Math.round(totalIndexed / indexingBlockAmount * 10000) / 100; | ||||||
|             logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${runningFor} seconds`, logger.tags.mining); |             logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexingBlockAmount} (${progress.toFixed(2)}%) | elapsed: ${runningFor.toFixed(2)} seconds`, logger.tags.mining); | ||||||
|             timer = new Date().getTime() / 1000; |             timer = Date.now() / 1000; | ||||||
|             indexedThisRun = 0; |             indexedThisRun = 0; | ||||||
|             loadingIndicators.setProgress('block-indexing', progress, false); |             loadingIndicators.setProgress('block-indexing', progress, false); | ||||||
|           } |           } | ||||||
|           const blockHash = await bitcoinApi.$getBlockHash(blockHeight); |           const blockHash = await bitcoinApi.$getBlockHash(blockHeight); | ||||||
|           const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); |           const block: IEsploraApi.Block = await bitcoinApi.$getBlock(blockHash); | ||||||
|           const transactions = await this.$getTransactionsExtended(blockHash, block.height, true, null, true); |           const transactions = await this.$getTransactionsExtended(blockHash, block.height, true, null, true); | ||||||
|           const blockExtended = await this.$getBlockExtended(block, transactions); |           const blockExtended = await this.$getBlockExtended(block, transactions); | ||||||
| 
 | 
 | ||||||
| @ -613,17 +664,17 @@ class Blocks { | |||||||
|         const heightDiff = blockHeightTip % 2016; |         const heightDiff = blockHeightTip % 2016; | ||||||
|         const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff); |         const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff); | ||||||
|         this.updateTimerProgress(timer, 'got block hash for initial difficulty adjustment'); |         this.updateTimerProgress(timer, 'got block hash for initial difficulty adjustment'); | ||||||
|         const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); |         const block: IEsploraApi.Block = await bitcoinApi.$getBlock(blockHash); | ||||||
|         this.updateTimerProgress(timer, 'got block for initial difficulty adjustment'); |         this.updateTimerProgress(timer, 'got block for initial difficulty adjustment'); | ||||||
|         this.lastDifficultyAdjustmentTime = block.timestamp; |         this.lastDifficultyAdjustmentTime = block.timestamp; | ||||||
|         this.currentDifficulty = block.difficulty; |         this.currentBits = block.bits; | ||||||
| 
 | 
 | ||||||
|         if (blockHeightTip >= 2016) { |         if (blockHeightTip >= 2016) { | ||||||
|           const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016); |           const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016); | ||||||
|           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 bitcoinCoreApi.$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 = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100; |           this.previousDifficultyRetarget = calcBitsDifference(previousPeriodBlock.bits, block.bits); | ||||||
|           logger.debug(`Initial difficulty adjustment data set.`); |           logger.debug(`Initial difficulty adjustment data set.`); | ||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
| @ -647,14 +698,14 @@ class Blocks { | |||||||
|       const block = BitcoinApi.convertBlock(verboseBlock); |       const block = BitcoinApi.convertBlock(verboseBlock); | ||||||
|       const txIds: string[] = verboseBlock.tx.map(tx => tx.txid); |       const txIds: string[] = verboseBlock.tx.map(tx => tx.txid); | ||||||
|       const transactions = await this.$getTransactionsExtended(blockHash, block.height, false, txIds, false, true) as MempoolTransactionExtended[]; |       const transactions = await this.$getTransactionsExtended(blockHash, block.height, false, txIds, false, true) as MempoolTransactionExtended[]; | ||||||
|       if (config.MEMPOOL.BACKEND !== 'esplora') { | 
 | ||||||
|       // fill in missing transaction fee data from verboseBlock
 |       // fill in missing transaction fee data from verboseBlock
 | ||||||
|       for (let i = 0; i < transactions.length; i++) { |       for (let i = 0; i < transactions.length; i++) { | ||||||
|         if (!transactions[i].fee && transactions[i].txid === verboseBlock.tx[i].txid) { |         if (!transactions[i].fee && transactions[i].txid === verboseBlock.tx[i].txid) { | ||||||
|             transactions[i].fee = verboseBlock.tx[i].fee * 100_000_000; |           transactions[i].fee = (verboseBlock.tx[i].fee * 100_000_000) || 0; | ||||||
|           } |  | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|       const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions); |       const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions); | ||||||
|       const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions); |       const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions); | ||||||
|       const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, cpfpSummary.transactions); |       const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, cpfpSummary.transactions); | ||||||
| @ -736,14 +787,18 @@ class Blocks { | |||||||
|             time: block.timestamp, |             time: block.timestamp, | ||||||
|             height: block.height, |             height: block.height, | ||||||
|             difficulty: block.difficulty, |             difficulty: block.difficulty, | ||||||
|             adjustment: Math.round((block.difficulty / this.currentDifficulty) * 1000000) / 1000000, // Remove float point noise
 |             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
 | ||||||
|           }); |           }); | ||||||
|           this.updateTimerProgress(timer, `saved difficulty adjustment for ${this.currentBlockHeight}`); |           this.updateTimerProgress(timer, `saved difficulty adjustment for ${this.currentBlockHeight}`); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100; |         this.previousDifficultyRetarget = calcBitsDifference(this.currentBits, block.bits); | ||||||
|         this.lastDifficultyAdjustmentTime = block.timestamp; |         this.lastDifficultyAdjustmentTime = block.timestamp; | ||||||
|         this.currentDifficulty = block.difficulty; |         this.currentBits = block.bits; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       // wait for pending async callbacks to finish
 |       // wait for pending async callbacks to finish
 | ||||||
| @ -763,10 +818,18 @@ class Blocks { | |||||||
|       if (this.newBlockCallbacks.length) { |       if (this.newBlockCallbacks.length) { | ||||||
|         this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions)); |         this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions)); | ||||||
|       } |       } | ||||||
|       if (!memPool.hasPriority() && (block.height % config.MEMPOOL.DISK_CACHE_BLOCK_INTERVAL === 0)) { |       if (config.MEMPOOL.CACHE_ENABLED && !memPool.hasPriority() && (block.height % config.MEMPOOL.DISK_CACHE_BLOCK_INTERVAL === 0)) { | ||||||
|         diskCache.$saveCacheToDisk(); |         diskCache.$saveCacheToDisk(); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       // Update Redis cache
 | ||||||
|  |       if (config.REDIS.ENABLED) { | ||||||
|  |         await redisCache.$updateBlocks(this.blocks); | ||||||
|  |         await redisCache.$updateBlockSummaries(this.blockSummaries); | ||||||
|  |         await redisCache.$removeTransactions(txIds); | ||||||
|  |         await rbfCache.updateCache(); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       handledBlocks++; |       handledBlocks++; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -811,7 +874,7 @@ class Blocks { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const blockHash = await bitcoinApi.$getBlockHash(height); |     const blockHash = await bitcoinApi.$getBlockHash(height); | ||||||
|     const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); |     const block: IEsploraApi.Block = await bitcoinApi.$getBlock(blockHash); | ||||||
|     const transactions = await this.$getTransactionsExtended(blockHash, block.height, true); |     const transactions = await this.$getTransactionsExtended(blockHash, block.height, true); | ||||||
|     const blockExtended = await this.$getBlockExtended(block, transactions); |     const blockExtended = await this.$getBlockExtended(block, transactions); | ||||||
| 
 | 
 | ||||||
| @ -823,7 +886,7 @@ class Blocks { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public async $indexStaleBlock(hash: string): Promise<BlockExtended> { |   public async $indexStaleBlock(hash: string): Promise<BlockExtended> { | ||||||
|     const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(hash); |     const block: IEsploraApi.Block = await bitcoinApi.$getBlock(hash); | ||||||
|     const transactions = await this.$getTransactionsExtended(hash, block.height, true); |     const transactions = await this.$getTransactionsExtended(hash, block.height, true); | ||||||
|     const blockExtended = await this.$getBlockExtended(block, transactions); |     const blockExtended = await this.$getBlockExtended(block, transactions); | ||||||
| 
 | 
 | ||||||
| @ -848,7 +911,7 @@ class Blocks { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Bitcoin network, add our custom data on top
 |     // Bitcoin network, add our custom data on top
 | ||||||
|     const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(hash); |     const block: IEsploraApi.Block = await bitcoinApi.$getBlock(hash); | ||||||
|     if (block.stale) { |     if (block.stale) { | ||||||
|       return await this.$indexStaleBlock(hash); |       return await this.$indexStaleBlock(hash); | ||||||
|     } else { |     } else { | ||||||
| @ -877,25 +940,30 @@ class Blocks { | |||||||
| 
 | 
 | ||||||
|     let height = blockHeight; |     let height = blockHeight; | ||||||
|     let summary: BlockSummary; |     let summary: BlockSummary; | ||||||
|     if (cpfpSummary) { |     if (cpfpSummary && !Common.isLiquid()) { | ||||||
|       summary = { |       summary = { | ||||||
|         id: hash, |         id: hash, | ||||||
|         transactions: cpfpSummary.transactions.map(tx => { |         transactions: cpfpSummary.transactions.map(tx => { | ||||||
|           return { |           return { | ||||||
|             txid: tx.txid, |             txid: tx.txid, | ||||||
|             fee: tx.fee, |             fee: tx.fee || 0, | ||||||
|             vsize: tx.vsize, |             vsize: tx.vsize, | ||||||
|             value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)), |             value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)), | ||||||
|             rate: tx.effectiveFeePerVsize |             rate: tx.effectiveFeePerVsize | ||||||
|           }; |           }; | ||||||
|         }), |         }), | ||||||
|       }; |       }; | ||||||
|  |     } else { | ||||||
|  |       if (config.MEMPOOL.BACKEND === 'esplora') { | ||||||
|  |         const txs = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx)); | ||||||
|  |         summary = this.summarizeBlockTransactions(hash, txs); | ||||||
|       } else { |       } else { | ||||||
|         // Call Core RPC
 |         // Call Core RPC
 | ||||||
|         const block = await bitcoinClient.getBlock(hash, 2); |         const block = await bitcoinClient.getBlock(hash, 2); | ||||||
|         summary = this.summarizeBlock(block); |         summary = this.summarizeBlock(block); | ||||||
|         height = block.height; |         height = block.height; | ||||||
|       } |       } | ||||||
|  |     } | ||||||
|     if (height == null) { |     if (height == null) { | ||||||
|       const block = await bitcoinApi.$getBlock(hash); |       const block = await bitcoinApi.$getBlock(hash); | ||||||
|       height = block.height; |       height = block.height; | ||||||
| @ -1017,8 +1085,17 @@ class Blocks { | |||||||
|       if (Common.blocksSummariesIndexingEnabled() && cleanBlock.fee_amt_percentiles === null) { |       if (Common.blocksSummariesIndexingEnabled() && cleanBlock.fee_amt_percentiles === null) { | ||||||
|         cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash); |         cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash); | ||||||
|         if (cleanBlock.fee_amt_percentiles === null) { |         if (cleanBlock.fee_amt_percentiles === null) { | ||||||
|  | 
 | ||||||
|  |           let summary; | ||||||
|  |           if (config.MEMPOOL.BACKEND === 'esplora') { | ||||||
|  |             const txs = (await bitcoinApi.$getTxsForBlock(cleanBlock.hash)).map(tx => transactionUtils.extendTransaction(tx)); | ||||||
|  |             summary = this.summarizeBlockTransactions(cleanBlock.hash, txs); | ||||||
|  |           } else { | ||||||
|  |             // Call Core RPC
 | ||||||
|             const block = await bitcoinClient.getBlock(cleanBlock.hash, 2); |             const block = await bitcoinClient.getBlock(cleanBlock.hash, 2); | ||||||
|           const summary = this.summarizeBlock(block); |             summary = this.summarizeBlock(block); | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|           await BlocksSummariesRepository.$saveTransactions(cleanBlock.height, cleanBlock.hash, summary.transactions); |           await BlocksSummariesRepository.$saveTransactions(cleanBlock.height, cleanBlock.hash, summary.transactions); | ||||||
|           cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash); |           cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash); | ||||||
|         } |         } | ||||||
| @ -1078,19 +1155,29 @@ class Blocks { | |||||||
|     return this.currentBlockHeight; |     return this.currentBlockHeight; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public async $indexCPFP(hash: string, height: number): Promise<void> { |   public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise<CpfpSummary> { | ||||||
|  |     let transactions = txs; | ||||||
|  |     if (!transactions) { | ||||||
|  |       if (config.MEMPOOL.BACKEND === 'esplora') { | ||||||
|  |         transactions = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx)); | ||||||
|  |       } | ||||||
|  |       if (!transactions) { | ||||||
|         const block = await bitcoinClient.getBlock(hash, 2); |         const block = await bitcoinClient.getBlock(hash, 2); | ||||||
|     const transactions = block.tx.map(tx => { |         transactions = block.tx.map(tx => { | ||||||
|           tx.fee *= 100_000_000; |           tx.fee *= 100_000_000; | ||||||
|           return tx; |           return tx; | ||||||
|         }); |         }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     const summary = Common.calculateCpfp(height, transactions); |     const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]); | ||||||
| 
 | 
 | ||||||
|     await this.$saveCpfp(hash, height, summary); |     await this.$saveCpfp(hash, height, summary); | ||||||
| 
 | 
 | ||||||
|     const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions); |     const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions); | ||||||
|     await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats); |     await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats); | ||||||
|  | 
 | ||||||
|  |     return summary; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> { |   public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> { | ||||||
|  | |||||||
| @ -108,7 +108,7 @@ export class Common { | |||||||
|   static stripTransaction(tx: TransactionExtended): TransactionStripped { |   static stripTransaction(tx: TransactionExtended): TransactionStripped { | ||||||
|     return { |     return { | ||||||
|       txid: tx.txid, |       txid: tx.txid, | ||||||
|       fee: tx.fee, |       fee: tx.fee || 0, | ||||||
|       vsize: tx.weight / 4, |       vsize: tx.weight / 4, | ||||||
|       value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0), |       value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0), | ||||||
|       rate: tx.effectiveFeePerVsize, |       rate: tx.effectiveFeePerVsize, | ||||||
|  | |||||||
| @ -16,6 +16,68 @@ export interface DifficultyAdjustment { | |||||||
|   expectedBlocks: number;         // Block count
 |   expectedBlocks: number;         // Block count
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Calculate the difficulty increase/decrease by using the `bits` integer contained in two | ||||||
|  |  * block headers. | ||||||
|  |  * | ||||||
|  |  * Warning: Only compare `bits` from blocks in two adjacent difficulty periods. This code | ||||||
|  |  * assumes the maximum difference is x4 or /4 (as per the protocol) and will throw an | ||||||
|  |  * error if an exponent difference of 2 or more is seen. | ||||||
|  |  * | ||||||
|  |  * @param {number} oldBits The 32 bit `bits` integer from a block header. | ||||||
|  |  * @param {number} newBits The 32 bit `bits` integer from a block header in the next difficulty period. | ||||||
|  |  * @returns {number} A floating point decimal of the difficulty change from old to new. | ||||||
|  |  *          (ie. 21.3 means 21.3% increase in difficulty, -21.3 is a 21.3% decrease in difficulty) | ||||||
|  |  */ | ||||||
|  | export function calcBitsDifference(oldBits: number, newBits: number): number { | ||||||
|  |   // Must be
 | ||||||
|  |   // - integer
 | ||||||
|  |   // - highest exponent is 0x1f, so max value (as integer) is 0x1f0000ff
 | ||||||
|  |   // - min value is 1 (exponent = 0)
 | ||||||
|  |   // - highest bit of the number-part is +- sign, it must not be 1
 | ||||||
|  |   const verifyBits = (bits: number): void => { | ||||||
|  |     if ( | ||||||
|  |       Math.floor(bits) !== bits || | ||||||
|  |       bits > 0x1f0000ff || | ||||||
|  |       bits < 1 || | ||||||
|  |       (bits & 0x00800000) !== 0 || | ||||||
|  |       (bits & 0x007fffff) === 0 | ||||||
|  |     ) { | ||||||
|  |       throw new Error('Invalid bits'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |   verifyBits(oldBits); | ||||||
|  |   verifyBits(newBits); | ||||||
|  | 
 | ||||||
|  |   // No need to mask exponents because we checked the bounds above
 | ||||||
|  |   const oldExp = oldBits >> 24; | ||||||
|  |   const newExp = newBits >> 24; | ||||||
|  |   const oldNum = oldBits & 0x007fffff; | ||||||
|  |   const newNum = newBits & 0x007fffff; | ||||||
|  |   // The diff can only possibly be 1, 0, -1
 | ||||||
|  |   // (because maximum difficulty change is x4 or /4 (2 bits up or down))
 | ||||||
|  |   let result: number; | ||||||
|  |   switch (newExp - oldExp) { | ||||||
|  |     // New less than old, target lowered, difficulty increased
 | ||||||
|  |     case -1: | ||||||
|  |       result = ((oldNum << 8) * 100) / newNum - 100; | ||||||
|  |       break; | ||||||
|  |     // Same exponent, compare numbers as is.
 | ||||||
|  |     case 0: | ||||||
|  |       result = (oldNum * 100) / newNum - 100; | ||||||
|  |       break; | ||||||
|  |     // Old less than new, target raised, difficulty decreased
 | ||||||
|  |     case 1: | ||||||
|  |       result = (oldNum * 100) / (newNum << 8) - 100; | ||||||
|  |       break; | ||||||
|  |     default: | ||||||
|  |       throw new Error('Impossible exponent difference'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Min/Max values
 | ||||||
|  |   return result > 300 ? 300 : result < -75 ? -75 : result; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function calcDifficultyAdjustment( | export function calcDifficultyAdjustment( | ||||||
|   DATime: number, |   DATime: number, | ||||||
|   nowSeconds: number, |   nowSeconds: number, | ||||||
|  | |||||||
| @ -29,7 +29,7 @@ class DiskCache { | |||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   constructor() { |   constructor() { | ||||||
|     if (!cluster.isPrimary) { |     if (!cluster.isPrimary || !config.MEMPOOL.CACHE_ENABLED) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     process.on('SIGINT', (e) => { |     process.on('SIGINT', (e) => { | ||||||
| @ -39,7 +39,7 @@ class DiskCache { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async $saveCacheToDisk(sync: boolean = false): Promise<void> { |   async $saveCacheToDisk(sync: boolean = false): Promise<void> { | ||||||
|     if (!cluster.isPrimary) { |     if (!cluster.isPrimary || !config.MEMPOOL.CACHE_ENABLED) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     if (this.isWritingCache) { |     if (this.isWritingCache) { | ||||||
| @ -175,10 +175,11 @@ class DiskCache { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async $loadMempoolCache(): Promise<void> { |   async $loadMempoolCache(): Promise<void> { | ||||||
|     if (!fs.existsSync(DiskCache.FILE_NAME)) { |     if (!config.MEMPOOL.CACHE_ENABLED || !fs.existsSync(DiskCache.FILE_NAME)) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     try { |     try { | ||||||
|  |       const start = Date.now(); | ||||||
|       let data: any = {}; |       let data: any = {}; | ||||||
|       const cacheData = fs.readFileSync(DiskCache.FILE_NAME, 'utf8'); |       const cacheData = fs.readFileSync(DiskCache.FILE_NAME, 'utf8'); | ||||||
|       if (cacheData) { |       if (cacheData) { | ||||||
| @ -220,6 +221,8 @@ class DiskCache { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       logger.info(`Loaded mempool from disk cache in ${Date.now() - start} ms`); | ||||||
|  | 
 | ||||||
|       await memPool.$setMempool(data.mempool); |       await memPool.$setMempool(data.mempool); | ||||||
|       if (!this.ignoreBlocksCache) { |       if (!this.ignoreBlocksCache) { | ||||||
|         blocks.setBlocks(data.blocks); |         blocks.setBlocks(data.blocks); | ||||||
|  | |||||||
| @ -80,7 +80,7 @@ class ChannelsApi { | |||||||
| 
 | 
 | ||||||
|   public async $searchChannelsById(search: string): Promise<any[]> { |   public async $searchChannelsById(search: string): Promise<any[]> { | ||||||
|     try { |     try { | ||||||
|       const searchStripped = search.replace('%', '') + '%'; |       const searchStripped = search.replace(/[^0-9x]/g, '') + '%'; | ||||||
|       const query = `SELECT id, short_id, capacity, status FROM channels WHERE id LIKE ? OR short_id LIKE ? LIMIT 10`; |       const query = `SELECT id, short_id, capacity, status FROM channels WHERE id LIKE ? OR short_id LIKE ? LIMIT 10`; | ||||||
|       const [rows]: any = await DB.query(query, [searchStripped, searchStripped]); |       const [rows]: any = await DB.query(query, [searchStripped, searchStripped]); | ||||||
|       return rows; |       return rows; | ||||||
|  | |||||||
| @ -217,7 +217,7 @@ async function buildFullChannel(clChannelA: any, clChannelB: any): Promise<ILigh | |||||||
| 
 | 
 | ||||||
|   return { |   return { | ||||||
|     channel_id: Common.channelShortIdToIntegerId(clChannelA.short_channel_id), |     channel_id: Common.channelShortIdToIntegerId(clChannelA.short_channel_id), | ||||||
|     capacity: clChannelA.satoshis, |     capacity: (clChannelA.amount_msat / 1000).toString(), | ||||||
|     last_update: lastUpdate, |     last_update: lastUpdate, | ||||||
|     node1_policy: convertPolicy(clChannelA), |     node1_policy: convertPolicy(clChannelA), | ||||||
|     node2_policy: convertPolicy(clChannelB), |     node2_policy: convertPolicy(clChannelB), | ||||||
| @ -241,7 +241,7 @@ async function buildIncompleteChannel(clChannel: any): Promise<ILightningApi.Cha | |||||||
| 
 | 
 | ||||||
|   return { |   return { | ||||||
|     channel_id: Common.channelShortIdToIntegerId(clChannel.short_channel_id), |     channel_id: Common.channelShortIdToIntegerId(clChannel.short_channel_id), | ||||||
|     capacity: clChannel.satoshis, |     capacity: (clChannel.amount_msat / 1000).toString(), | ||||||
|     last_update: clChannel.last_update ?? 0, |     last_update: clChannel.last_update ?? 0, | ||||||
|     node1_policy: convertPolicy(clChannel), |     node1_policy: convertPolicy(clChannel), | ||||||
|     node2_policy: null, |     node2_policy: null, | ||||||
| @ -257,8 +257,8 @@ async function buildIncompleteChannel(clChannel: any): Promise<ILightningApi.Cha | |||||||
| function convertPolicy(clChannel: any): ILightningApi.RoutingPolicy { | function convertPolicy(clChannel: any): ILightningApi.RoutingPolicy { | ||||||
|   return { |   return { | ||||||
|     time_lock_delta: clChannel.delay, |     time_lock_delta: clChannel.delay, | ||||||
|     min_htlc: clChannel.htlc_minimum_msat.slice(0, -4), |     min_htlc: clChannel.htlc_minimum_msat.toString(), | ||||||
|     max_htlc_msat: clChannel.htlc_maximum_msat.slice(0, -4), |     max_htlc_msat: clChannel.htlc_maximum_msat.toString(), | ||||||
|     fee_base_msat: clChannel.base_fee_millisatoshi, |     fee_base_msat: clChannel.base_fee_millisatoshi, | ||||||
|     fee_rate_milli_msat: clChannel.fee_per_millionth, |     fee_rate_milli_msat: clChannel.fee_per_millionth, | ||||||
|     disabled: !clChannel.active, |     disabled: !clChannel.active, | ||||||
|  | |||||||
| @ -9,6 +9,7 @@ import loadingIndicators from './loading-indicators'; | |||||||
| import bitcoinClient from './bitcoin/bitcoin-client'; | import bitcoinClient from './bitcoin/bitcoin-client'; | ||||||
| import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; | import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; | ||||||
| import rbfCache from './rbf-cache'; | import rbfCache from './rbf-cache'; | ||||||
|  | import redisCache from './redis-cache'; | ||||||
| 
 | 
 | ||||||
| class Mempool { | class Mempool { | ||||||
|   private inSync: boolean = false; |   private inSync: boolean = false; | ||||||
| @ -85,14 +86,25 @@ class Mempool { | |||||||
|   public async $setMempool(mempoolData: { [txId: string]: MempoolTransactionExtended }) { |   public async $setMempool(mempoolData: { [txId: string]: MempoolTransactionExtended }) { | ||||||
|     this.mempoolCache = mempoolData; |     this.mempoolCache = mempoolData; | ||||||
|     let count = 0; |     let count = 0; | ||||||
|  |     const redisTimer = Date.now(); | ||||||
|  |     if (config.MEMPOOL.CACHE_ENABLED && config.REDIS.ENABLED) { | ||||||
|  |       logger.debug(`Migrating ${Object.keys(this.mempoolCache).length} transactions from disk cache to Redis cache`); | ||||||
|  |     } | ||||||
|     for (const txid of Object.keys(this.mempoolCache)) { |     for (const txid of Object.keys(this.mempoolCache)) { | ||||||
|       if (this.mempoolCache[txid].sigops == null || this.mempoolCache[txid].effectiveFeePerVsize == null) { |       if (!this.mempoolCache[txid].sigops || this.mempoolCache[txid].effectiveFeePerVsize == null) { | ||||||
|         this.mempoolCache[txid] = transactionUtils.extendMempoolTransaction(this.mempoolCache[txid]); |         this.mempoolCache[txid] = transactionUtils.extendMempoolTransaction(this.mempoolCache[txid]); | ||||||
|       } |       } | ||||||
|       if (this.mempoolCache[txid].order == null) { |       if (this.mempoolCache[txid].order == null) { | ||||||
|         this.mempoolCache[txid].order = transactionUtils.txidToOrdering(txid); |         this.mempoolCache[txid].order = transactionUtils.txidToOrdering(txid); | ||||||
|       } |       } | ||||||
|       count++; |       count++; | ||||||
|  |       if (config.MEMPOOL.CACHE_ENABLED && config.REDIS.ENABLED) { | ||||||
|  |         await redisCache.$addTransaction(this.mempoolCache[txid]); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (config.MEMPOOL.CACHE_ENABLED && config.REDIS.ENABLED) { | ||||||
|  |       await redisCache.$flushTransactions(); | ||||||
|  |       logger.debug(`Finished migrating cache transactions in ${((Date.now() - redisTimer) / 1000).toFixed(2)} seconds`); | ||||||
|     } |     } | ||||||
|     if (this.mempoolChangedCallback) { |     if (this.mempoolChangedCallback) { | ||||||
|       this.mempoolChangedCallback(this.mempoolCache, [], []); |       this.mempoolChangedCallback(this.mempoolCache, [], []); | ||||||
| @ -103,6 +115,44 @@ class Mempool { | |||||||
|     this.addToSpendMap(Object.values(this.mempoolCache)); |     this.addToSpendMap(Object.values(this.mempoolCache)); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   public async $reloadMempool(expectedCount: number): Promise<MempoolTransactionExtended[]> { | ||||||
|  |     let count = 0; | ||||||
|  |     let done = false; | ||||||
|  |     let last_txid; | ||||||
|  |     const newTransactions: MempoolTransactionExtended[] = []; | ||||||
|  |     loadingIndicators.setProgress('mempool', count / expectedCount * 100); | ||||||
|  |     while (!done) { | ||||||
|  |       try { | ||||||
|  |         const result = await bitcoinApi.$getMempoolTransactions(last_txid); | ||||||
|  |         if (result) { | ||||||
|  |           for (const tx of result) { | ||||||
|  |             const extendedTransaction = transactionUtils.extendMempoolTransaction(tx); | ||||||
|  |             if (!this.mempoolCache[extendedTransaction.txid]) { | ||||||
|  |               newTransactions.push(extendedTransaction); | ||||||
|  |               this.mempoolCache[extendedTransaction.txid] = extendedTransaction; | ||||||
|  |             } | ||||||
|  |             count++; | ||||||
|  |           } | ||||||
|  |           logger.info(`Fetched ${count} of ${expectedCount} mempool transactions from esplora`); | ||||||
|  |           if (result.length > 0) { | ||||||
|  |             last_txid = result[result.length - 1].txid; | ||||||
|  |           } else { | ||||||
|  |             done = true; | ||||||
|  |           } | ||||||
|  |           if (Math.floor((count / expectedCount) * 100) < 100) { | ||||||
|  |             loadingIndicators.setProgress('mempool', count / expectedCount * 100); | ||||||
|  |           } | ||||||
|  |         } else { | ||||||
|  |           done = true; | ||||||
|  |         } | ||||||
|  |       } catch(err) { | ||||||
|  |         logger.err('failed to fetch bulk mempool transactions from esplora'); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     logger.info(`Done inserting loaded mempool transactions into local cache`); | ||||||
|  |     return newTransactions; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   public async $updateMemPoolInfo() { |   public async $updateMemPoolInfo() { | ||||||
|     this.mempoolInfo = await this.$getMempoolInfo(); |     this.mempoolInfo = await this.$getMempoolInfo(); | ||||||
|   } |   } | ||||||
| @ -132,7 +182,7 @@ class Mempool { | |||||||
|     return txTimes; |     return txTimes; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public async $updateMempool(transactions: string[]): Promise<void> { |   public async $updateMempool(transactions: string[], pollRate: number): Promise<void> { | ||||||
|     logger.debug(`Updating mempool...`); |     logger.debug(`Updating mempool...`); | ||||||
| 
 | 
 | ||||||
|     // warn if this run stalls the main loop for more than 2 minutes
 |     // warn if this run stalls the main loop for more than 2 minutes
 | ||||||
| @ -143,7 +193,7 @@ class Mempool { | |||||||
|     const currentMempoolSize = Object.keys(this.mempoolCache).length; |     const currentMempoolSize = Object.keys(this.mempoolCache).length; | ||||||
|     this.updateTimerProgress(timer, 'got raw mempool'); |     this.updateTimerProgress(timer, 'got raw mempool'); | ||||||
|     const diff = transactions.length - currentMempoolSize; |     const diff = transactions.length - currentMempoolSize; | ||||||
|     const newTransactions: MempoolTransactionExtended[] = []; |     let newTransactions: MempoolTransactionExtended[] = []; | ||||||
| 
 | 
 | ||||||
|     this.mempoolCacheDelta = Math.abs(diff); |     this.mempoolCacheDelta = Math.abs(diff); | ||||||
| 
 | 
 | ||||||
| @ -162,6 +212,25 @@ class Mempool { | |||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     let intervalTimer = Date.now(); |     let intervalTimer = Date.now(); | ||||||
|  | 
 | ||||||
|  |     let loaded = false; | ||||||
|  |     if (config.MEMPOOL.BACKEND === 'esplora' && currentMempoolSize < transactions.length * 0.5 && transactions.length > 20_000) { | ||||||
|  |       this.inSync = false; | ||||||
|  |       logger.info(`Missing ${transactions.length - currentMempoolSize} mempool transactions, attempting to reload in bulk from esplora`); | ||||||
|  |       try { | ||||||
|  |         newTransactions = await this.$reloadMempool(transactions.length); | ||||||
|  |         if (config.REDIS.ENABLED) { | ||||||
|  |           for (const tx of newTransactions) { | ||||||
|  |             await redisCache.$addTransaction(tx); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         loaded = true; | ||||||
|  |       } catch (e) { | ||||||
|  |         logger.err('failed to load mempool in bulk from esplora, falling back to fetching individual transactions'); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (!loaded) { | ||||||
|       for (const txid of transactions) { |       for (const txid of transactions) { | ||||||
|         if (!this.mempoolCache[txid]) { |         if (!this.mempoolCache[txid]) { | ||||||
|           try { |           try { | ||||||
| @ -177,6 +246,10 @@ class Mempool { | |||||||
|             } |             } | ||||||
|             hasChange = true; |             hasChange = true; | ||||||
|             newTransactions.push(transaction); |             newTransactions.push(transaction); | ||||||
|  | 
 | ||||||
|  |             if (config.REDIS.ENABLED) { | ||||||
|  |               await redisCache.$addTransaction(transaction); | ||||||
|  |             } | ||||||
|           } catch (e: any) { |           } catch (e: any) { | ||||||
|             if (config.MEMPOOL.BACKEND === 'esplora' && e.response?.status === 404) { |             if (config.MEMPOOL.BACKEND === 'esplora' && e.response?.status === 404) { | ||||||
|               this.missingTxCount++; |               this.missingTxCount++; | ||||||
| @ -185,8 +258,7 @@ class Mempool { | |||||||
|           } |           } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|       if (Date.now() - intervalTimer > 5_000) { |         if (Date.now() - intervalTimer > Math.max(pollRate * 2, 5_000)) { | ||||||
|          |  | ||||||
|           if (this.inSync) { |           if (this.inSync) { | ||||||
|             // Break and restart mempool loop if we spend too much time processing
 |             // Break and restart mempool loop if we spend too much time processing
 | ||||||
|             // new transactions that may lead to falling behind on block height
 |             // new transactions that may lead to falling behind on block height
 | ||||||
| @ -195,8 +267,11 @@ class Mempool { | |||||||
|           } else { |           } else { | ||||||
|             const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100; |             const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100; | ||||||
|             logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`); |             logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`); | ||||||
|  |             if (Math.floor(progress) < 100) { | ||||||
|               loadingIndicators.setProgress('mempool', progress); |               loadingIndicators.setProgress('mempool', progress); | ||||||
|           intervalTimer = Date.now() |             } | ||||||
|  |             intervalTimer = Date.now(); | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| @ -219,7 +294,7 @@ class Mempool { | |||||||
|       logger.warn(`Mempool clear protection triggered because transactions.length: ${transactions.length} and currentMempoolSize: ${currentMempoolSize}.`); |       logger.warn(`Mempool clear protection triggered because transactions.length: ${transactions.length} and currentMempoolSize: ${currentMempoolSize}.`); | ||||||
|       setTimeout(() => { |       setTimeout(() => { | ||||||
|         this.mempoolProtection = 2; |         this.mempoolProtection = 2; | ||||||
|         logger.warn('Mempool clear protection resumed.'); |         logger.warn('Mempool clear protection ended, normal operation resumed.'); | ||||||
|       }, 1000 * 60 * config.MEMPOOL.CLEAR_PROTECTION_MINUTES); |       }, 1000 * 60 * config.MEMPOOL.CLEAR_PROTECTION_MINUTES); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -246,12 +321,6 @@ class Mempool { | |||||||
|     const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx)); |     const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx)); | ||||||
|     this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6); |     this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6); | ||||||
| 
 | 
 | ||||||
|     if (!this.inSync && transactions.length === newMempoolSize) { |  | ||||||
|       this.inSync = true; |  | ||||||
|       logger.notice('The mempool is now in sync!'); |  | ||||||
|       loadingIndicators.setProgress('mempool', 100); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     this.mempoolCacheDelta = Math.abs(transactions.length - newMempoolSize); |     this.mempoolCacheDelta = Math.abs(transactions.length - newMempoolSize); | ||||||
| 
 | 
 | ||||||
|     if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { |     if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { | ||||||
| @ -263,6 +332,19 @@ class Mempool { | |||||||
|       this.updateTimerProgress(timer, 'completed async mempool callback'); |       this.updateTimerProgress(timer, 'completed async mempool callback'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (!this.inSync && transactions.length === newMempoolSize) { | ||||||
|  |       this.inSync = true; | ||||||
|  |       logger.notice('The mempool is now in sync!'); | ||||||
|  |       loadingIndicators.setProgress('mempool', 100); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Update Redis cache
 | ||||||
|  |     if (config.REDIS.ENABLED) { | ||||||
|  |       await redisCache.$flushTransactions(); | ||||||
|  |       await redisCache.$removeTransactions(deletedTransactions.map(tx => tx.txid)); | ||||||
|  |       await rbfCache.updateCache(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const end = new Date().getTime(); |     const end = new Date().getTime(); | ||||||
|     const time = end - start; |     const time = end - start; | ||||||
|     logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`); |     logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`); | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjust | |||||||
| import config from '../../config'; | import config from '../../config'; | ||||||
| import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository'; | import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository'; | ||||||
| import PricesRepository from '../../repositories/PricesRepository'; | import PricesRepository from '../../repositories/PricesRepository'; | ||||||
| import { bitcoinCoreApi } from '../bitcoin/bitcoin-api-factory'; | import bitcoinApi from '../bitcoin/bitcoin-api-factory'; | ||||||
| import { IEsploraApi } from '../bitcoin/esplora-api.interface'; | import { IEsploraApi } from '../bitcoin/esplora-api.interface'; | ||||||
| import database from '../../database'; | import database from '../../database'; | ||||||
| 
 | 
 | ||||||
| @ -201,7 +201,7 @@ class Mining { | |||||||
|     try { |     try { | ||||||
|       const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp; |       const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp; | ||||||
| 
 | 
 | ||||||
|       const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0)); |       const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0)); | ||||||
|       const genesisTimestamp = genesisBlock.timestamp * 1000; |       const genesisTimestamp = genesisBlock.timestamp * 1000; | ||||||
| 
 | 
 | ||||||
|       const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps(); |       const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps(); | ||||||
| @ -312,7 +312,7 @@ class Mining { | |||||||
|     const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp; |     const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp; | ||||||
| 
 | 
 | ||||||
|     try { |     try { | ||||||
|       const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0)); |       const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0)); | ||||||
|       const genesisTimestamp = genesisBlock.timestamp * 1000; |       const genesisTimestamp = genesisBlock.timestamp * 1000; | ||||||
|       const indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp); |       const indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp); | ||||||
|       const lastMidnight = this.getDateMidnight(new Date()); |       const lastMidnight = this.getDateMidnight(new Date()); | ||||||
| @ -421,8 +421,9 @@ class Mining { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const blocks: any = await BlocksRepository.$getBlocksDifficulty(); |     const blocks: any = await BlocksRepository.$getBlocksDifficulty(); | ||||||
|     const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0)); |     const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0)); | ||||||
|     let currentDifficulty = genesisBlock.difficulty; |     let currentDifficulty = genesisBlock.difficulty; | ||||||
|  |     let currentBits = genesisBlock.bits; | ||||||
|     let totalIndexed = 0; |     let totalIndexed = 0; | ||||||
| 
 | 
 | ||||||
|     if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) { |     if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) { | ||||||
| @ -436,6 +437,7 @@ class Mining { | |||||||
| 
 | 
 | ||||||
|     const oldestConsecutiveBlock = await BlocksRepository.$getOldestConsecutiveBlock(); |     const oldestConsecutiveBlock = await BlocksRepository.$getOldestConsecutiveBlock(); | ||||||
|     if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== -1) { |     if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== -1) { | ||||||
|  |       currentBits = oldestConsecutiveBlock.bits; | ||||||
|       currentDifficulty = oldestConsecutiveBlock.difficulty; |       currentDifficulty = oldestConsecutiveBlock.difficulty; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -443,10 +445,11 @@ class Mining { | |||||||
|     let timer = new Date().getTime() / 1000; |     let timer = new Date().getTime() / 1000; | ||||||
| 
 | 
 | ||||||
|     for (const block of blocks) { |     for (const block of blocks) { | ||||||
|       if (block.difficulty !== currentDifficulty) { |       if (block.bits !== currentBits) { | ||||||
|         if (indexedHeights[block.height] === true) { // Already indexed
 |         if (indexedHeights[block.height] === true) { // Already indexed
 | ||||||
|           if (block.height >= oldestConsecutiveBlock.height) { |           if (block.height >= oldestConsecutiveBlock.height) { | ||||||
|             currentDifficulty = block.difficulty; |             currentDifficulty = block.difficulty; | ||||||
|  |             currentBits = block.bits; | ||||||
|           } |           } | ||||||
|           continue;           |           continue;           | ||||||
|         } |         } | ||||||
| @ -464,6 +467,7 @@ class Mining { | |||||||
|         totalIndexed++; |         totalIndexed++; | ||||||
|         if (block.height >= oldestConsecutiveBlock.height) { |         if (block.height >= oldestConsecutiveBlock.height) { | ||||||
|           currentDifficulty = block.difficulty; |           currentDifficulty = block.difficulty; | ||||||
|  |           currentBits = block.bits; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,15 +1,17 @@ | |||||||
|  | import config from "../config"; | ||||||
| import logger from "../logger"; | import logger from "../logger"; | ||||||
| import { MempoolTransactionExtended, TransactionStripped } from "../mempool.interfaces"; | import { MempoolTransactionExtended, TransactionStripped } from "../mempool.interfaces"; | ||||||
| import bitcoinApi from './bitcoin/bitcoin-api-factory'; | import bitcoinApi from './bitcoin/bitcoin-api-factory'; | ||||||
| import { Common } from "./common"; | import { Common } from "./common"; | ||||||
|  | import redisCache from "./redis-cache"; | ||||||
| 
 | 
 | ||||||
| interface RbfTransaction extends TransactionStripped { | export interface RbfTransaction extends TransactionStripped { | ||||||
|   rbf?: boolean; |   rbf?: boolean; | ||||||
|   mined?: boolean; |   mined?: boolean; | ||||||
|   fullRbf?: boolean; |   fullRbf?: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| interface RbfTree { | export interface RbfTree { | ||||||
|   tx: RbfTransaction; |   tx: RbfTransaction; | ||||||
|   time: number; |   time: number; | ||||||
|   interval?: number; |   interval?: number; | ||||||
| @ -28,6 +30,19 @@ export interface ReplacementInfo { | |||||||
|   newVsize: number; |   newVsize: number; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | enum CacheOp { | ||||||
|  |   Remove = 0, | ||||||
|  |   Add = 1, | ||||||
|  |   Change = 2, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface CacheEvent { | ||||||
|  |   op: CacheOp; | ||||||
|  |   type: 'tx' | 'tree' | 'exp'; | ||||||
|  |   txid: string, | ||||||
|  |   value?: any, | ||||||
|  | } | ||||||
|  | 
 | ||||||
| class RbfCache { | class RbfCache { | ||||||
|   private replacedBy: Map<string, string> = new Map(); |   private replacedBy: Map<string, string> = new Map(); | ||||||
|   private replaces: Map<string, string[]> = new Map(); |   private replaces: Map<string, string[]> = new Map(); | ||||||
| @ -36,11 +51,43 @@ class RbfCache { | |||||||
|   private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
 |   private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
 | ||||||
|   private txs: Map<string, MempoolTransactionExtended> = new Map(); |   private txs: Map<string, MempoolTransactionExtended> = new Map(); | ||||||
|   private expiring: Map<string, number> = new Map(); |   private expiring: Map<string, number> = new Map(); | ||||||
|  |   private cacheQueue: CacheEvent[] = []; | ||||||
| 
 | 
 | ||||||
|   constructor() { |   constructor() { | ||||||
|     setInterval(this.cleanup.bind(this), 1000 * 60 * 10); |     setInterval(this.cleanup.bind(this), 1000 * 60 * 10); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   private addTx(txid: string, tx: MempoolTransactionExtended): void { | ||||||
|  |     this.txs.set(txid, tx); | ||||||
|  |     this.cacheQueue.push({ op: CacheOp.Add, type: 'tx', txid }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private addTree(txid: string, tree: RbfTree): void { | ||||||
|  |     this.rbfTrees.set(txid, tree); | ||||||
|  |     this.dirtyTrees.add(txid); | ||||||
|  |     this.cacheQueue.push({ op: CacheOp.Add, type: 'tree', txid }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private addExpiration(txid: string, expiry: number): void { | ||||||
|  |     this.expiring.set(txid, expiry); | ||||||
|  |     this.cacheQueue.push({ op: CacheOp.Add, type: 'exp', txid, value: expiry }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private removeTx(txid: string): void { | ||||||
|  |     this.txs.delete(txid); | ||||||
|  |     this.cacheQueue.push({ op: CacheOp.Remove, type: 'tx', txid }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private removeTree(txid: string): void { | ||||||
|  |     this.rbfTrees.delete(txid); | ||||||
|  |     this.cacheQueue.push({ op: CacheOp.Remove, type: 'tree', txid }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private removeExpiration(txid: string): void { | ||||||
|  |     this.expiring.delete(txid); | ||||||
|  |     this.cacheQueue.push({ op: CacheOp.Remove, type: 'exp', txid }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void { |   public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void { | ||||||
|     if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) { |     if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) { | ||||||
|       return; |       return; | ||||||
| @ -49,7 +96,7 @@ class RbfCache { | |||||||
|     const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction; |     const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction; | ||||||
|     const newTime = newTxExtended.firstSeen || (Date.now() / 1000); |     const newTime = newTxExtended.firstSeen || (Date.now() / 1000); | ||||||
|     newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe); |     newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe); | ||||||
|     this.txs.set(newTx.txid, newTxExtended); |     this.addTx(newTx.txid, newTxExtended); | ||||||
| 
 | 
 | ||||||
|     // maintain rbf trees
 |     // maintain rbf trees
 | ||||||
|     let txFullRbf = false; |     let txFullRbf = false; | ||||||
| @ -66,7 +113,7 @@ class RbfCache { | |||||||
|         const treeId = this.treeMap.get(replacedTx.txid); |         const treeId = this.treeMap.get(replacedTx.txid); | ||||||
|         if (treeId) { |         if (treeId) { | ||||||
|           const tree = this.rbfTrees.get(treeId); |           const tree = this.rbfTrees.get(treeId); | ||||||
|           this.rbfTrees.delete(treeId); |           this.removeTree(treeId); | ||||||
|           if (tree) { |           if (tree) { | ||||||
|             tree.interval = newTime - tree?.time; |             tree.interval = newTime - tree?.time; | ||||||
|             replacedTrees.push(tree); |             replacedTrees.push(tree); | ||||||
| @ -83,7 +130,7 @@ class RbfCache { | |||||||
|           replaces: [], |           replaces: [], | ||||||
|         }); |         }); | ||||||
|         treeFullRbf = treeFullRbf || !replacedTx.rbf; |         treeFullRbf = treeFullRbf || !replacedTx.rbf; | ||||||
|         this.txs.set(replacedTx.txid, replacedTxExtended); |         this.addTx(replacedTx.txid, replacedTxExtended); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     newTx.fullRbf = txFullRbf; |     newTx.fullRbf = txFullRbf; | ||||||
| @ -94,10 +141,27 @@ class RbfCache { | |||||||
|       fullRbf: treeFullRbf, |       fullRbf: treeFullRbf, | ||||||
|       replaces: replacedTrees |       replaces: replacedTrees | ||||||
|     }; |     }; | ||||||
|     this.rbfTrees.set(treeId, newTree); |     this.addTree(treeId, newTree); | ||||||
|     this.updateTreeMap(treeId, newTree); |     this.updateTreeMap(treeId, newTree); | ||||||
|     this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid)); |     this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid)); | ||||||
|     this.dirtyTrees.add(treeId); |   } | ||||||
|  | 
 | ||||||
|  |   public has(txId: string): boolean { | ||||||
|  |     return this.txs.has(txId); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public anyInSameTree(txId: string, predicate: (tx: RbfTransaction) => boolean): boolean { | ||||||
|  |     const tree = this.getRbfTree(txId); | ||||||
|  |     if (!tree) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |     const txs = this.getTransactionsInTree(tree); | ||||||
|  |     for (const tx of txs) { | ||||||
|  |       if (predicate(tx)) { | ||||||
|  |         return true; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public getReplacedBy(txId: string): string | undefined { |   public getReplacedBy(txId: string): string | undefined { | ||||||
| @ -173,6 +237,7 @@ class RbfCache { | |||||||
|         this.setTreeMined(tree, txid); |         this.setTreeMined(tree, txid); | ||||||
|         tree.mined = true; |         tree.mined = true; | ||||||
|         this.dirtyTrees.add(treeId); |         this.dirtyTrees.add(treeId); | ||||||
|  |         this.cacheQueue.push({ op: CacheOp.Change, type: 'tree', txid: treeId }); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     this.evict(txid); |     this.evict(txid); | ||||||
| @ -181,7 +246,8 @@ class RbfCache { | |||||||
|   // flag a transaction as removed from the mempool
 |   // flag a transaction as removed from the mempool
 | ||||||
|   public evict(txid: string, fast: boolean = false): void { |   public evict(txid: string, fast: boolean = false): void { | ||||||
|     if (this.txs.has(txid) && (fast || !this.expiring.has(txid))) { |     if (this.txs.has(txid) && (fast || !this.expiring.has(txid))) { | ||||||
|       this.expiring.set(txid, fast ? Date.now() + (1000 * 60 * 10) : Date.now() + (1000 * 86400)); // 24 hours
 |       const expiryTime = fast ? Date.now() + (1000 * 60 * 10) : Date.now() + (1000 * 86400); // 24 hours
 | ||||||
|  |       this.addExpiration(txid, expiryTime); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -202,11 +268,11 @@ class RbfCache { | |||||||
|     const now = Date.now(); |     const now = Date.now(); | ||||||
|     for (const txid of this.expiring.keys()) { |     for (const txid of this.expiring.keys()) { | ||||||
|       if ((this.expiring.get(txid) || 0) < now) { |       if ((this.expiring.get(txid) || 0) < now) { | ||||||
|         this.expiring.delete(txid); |         this.removeExpiration(txid); | ||||||
|         this.remove(txid); |         this.remove(txid); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     logger.debug(`rbf cache contains ${this.txs.size} txs, ${this.expiring.size} due to expire`); |     logger.debug(`rbf cache contains ${this.txs.size} txs, ${this.rbfTrees.size} trees, ${this.expiring.size} due to expire`); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // remove a transaction & all previous versions from the cache
 |   // remove a transaction & all previous versions from the cache
 | ||||||
| @ -216,14 +282,14 @@ class RbfCache { | |||||||
|       const replaces = this.replaces.get(txid); |       const replaces = this.replaces.get(txid); | ||||||
|       this.replaces.delete(txid); |       this.replaces.delete(txid); | ||||||
|       this.treeMap.delete(txid); |       this.treeMap.delete(txid); | ||||||
|       this.txs.delete(txid); |       this.removeTx(txid); | ||||||
|       this.expiring.delete(txid); |       this.removeExpiration(txid); | ||||||
|       for (const tx of (replaces || [])) { |       for (const tx of (replaces || [])) { | ||||||
|         // recursively remove prior versions from the cache
 |         // recursively remove prior versions from the cache
 | ||||||
|         this.replacedBy.delete(tx); |         this.replacedBy.delete(tx); | ||||||
|         // if this is the id of a tree, remove that too
 |         // if this is the id of a tree, remove that too
 | ||||||
|         if (this.treeMap.get(tx) === tx) { |         if (this.treeMap.get(tx) === tx) { | ||||||
|           this.rbfTrees.delete(tx); |           this.removeTree(tx); | ||||||
|         } |         } | ||||||
|         this.remove(tx); |         this.remove(tx); | ||||||
|       } |       } | ||||||
| @ -255,6 +321,33 @@ class RbfCache { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   public async updateCache(): Promise<void> { | ||||||
|  |     if (!config.REDIS.ENABLED) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     // Update the Redis cache by replaying queued events
 | ||||||
|  |     for (const e of this.cacheQueue) { | ||||||
|  |       if (e.op === CacheOp.Add || e.op === CacheOp.Change) { | ||||||
|  |         let value = e.value; | ||||||
|  |           switch(e.type) { | ||||||
|  |             case 'tx': { | ||||||
|  |               value = this.txs.get(e.txid); | ||||||
|  |             } break; | ||||||
|  |             case 'tree': { | ||||||
|  |               const tree = this.rbfTrees.get(e.txid); | ||||||
|  |               value = tree ? this.exportTree(tree) : null; | ||||||
|  |             } break; | ||||||
|  |           } | ||||||
|  |           if (value != null) { | ||||||
|  |             await redisCache.$setRbfEntry(e.type, e.txid, value); | ||||||
|  |           } | ||||||
|  |       } else if (e.op === CacheOp.Remove) { | ||||||
|  |         await redisCache.$removeRbfEntry(e.type, e.txid); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     this.cacheQueue = []; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   public dump(): any { |   public dump(): any { | ||||||
|     const trees = Array.from(this.rbfTrees.values()).map((tree: RbfTree) => { return this.exportTree(tree); }); |     const trees = Array.from(this.rbfTrees.values()).map((tree: RbfTree) => { return this.exportTree(tree); }); | ||||||
| 
 | 
 | ||||||
| @ -267,14 +360,14 @@ class RbfCache { | |||||||
| 
 | 
 | ||||||
|   public async load({ txs, trees, expiring }): Promise<void> { |   public async load({ txs, trees, expiring }): Promise<void> { | ||||||
|     txs.forEach(txEntry => { |     txs.forEach(txEntry => { | ||||||
|       this.txs.set(txEntry[0], txEntry[1]); |       this.txs.set(txEntry.key, txEntry.value); | ||||||
|     }); |     }); | ||||||
|     for (const deflatedTree of trees) { |     for (const deflatedTree of trees) { | ||||||
|       await this.importTree(deflatedTree.root, deflatedTree.root, deflatedTree, this.txs); |       await this.importTree(deflatedTree.root, deflatedTree.root, deflatedTree, this.txs); | ||||||
|     } |     } | ||||||
|     expiring.forEach(expiringEntry => { |     expiring.forEach(expiringEntry => { | ||||||
|       if (this.txs.has(expiringEntry[0])) { |       if (this.txs.has(expiringEntry.key)) { | ||||||
|         this.expiring.set(expiringEntry[0], new Date(expiringEntry[1]).getTime()); |         this.expiring.set(expiringEntry.key, new Date(expiringEntry.value).getTime()); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|     this.cleanup(); |     this.cleanup(); | ||||||
| @ -360,8 +453,7 @@ class RbfCache { | |||||||
|     }; |     }; | ||||||
|     this.treeMap.set(txid, root); |     this.treeMap.set(txid, root); | ||||||
|     if (root === txid) { |     if (root === txid) { | ||||||
|       this.rbfTrees.set(root, tree); |       this.addTree(root, tree); | ||||||
|       this.dirtyTrees.add(root); |  | ||||||
|     } |     } | ||||||
|     return tree; |     return tree; | ||||||
|   } |   } | ||||||
|  | |||||||
							
								
								
									
										276
									
								
								backend/src/api/redis-cache.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										276
									
								
								backend/src/api/redis-cache.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,276 @@ | |||||||
|  | import { createClient } from 'redis'; | ||||||
|  | import memPool from './mempool'; | ||||||
|  | import blocks from './blocks'; | ||||||
|  | import logger from '../logger'; | ||||||
|  | import config from '../config'; | ||||||
|  | import { BlockExtended, BlockSummary, MempoolTransactionExtended } from '../mempool.interfaces'; | ||||||
|  | import rbfCache from './rbf-cache'; | ||||||
|  | import transactionUtils from './transaction-utils'; | ||||||
|  | 
 | ||||||
|  | enum NetworkDB { | ||||||
|  |   mainnet = 0, | ||||||
|  |   testnet, | ||||||
|  |   signet, | ||||||
|  |   liquid, | ||||||
|  |   liquidtestnet, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class RedisCache { | ||||||
|  |   private client; | ||||||
|  |   private connected = false; | ||||||
|  |   private schemaVersion = 1; | ||||||
|  | 
 | ||||||
|  |   private cacheQueue: MempoolTransactionExtended[] = []; | ||||||
|  |   private txFlushLimit: number = 10000; | ||||||
|  | 
 | ||||||
|  |   constructor() { | ||||||
|  |     if (config.REDIS.ENABLED) { | ||||||
|  |       const redisConfig = { | ||||||
|  |         socket: { | ||||||
|  |           path: config.REDIS.UNIX_SOCKET_PATH | ||||||
|  |         }, | ||||||
|  |         database: NetworkDB[config.MEMPOOL.NETWORK], | ||||||
|  |       }; | ||||||
|  |       this.client = createClient(redisConfig); | ||||||
|  |       this.client.on('error', (e) => { | ||||||
|  |         logger.err(`Error in Redis client: ${e instanceof Error ? e.message : e}`); | ||||||
|  |       }); | ||||||
|  |       this.$ensureConnected(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private async $ensureConnected(): Promise<void> { | ||||||
|  |     if (!this.connected && config.REDIS.ENABLED) { | ||||||
|  |       return this.client.connect().then(async () => { | ||||||
|  |         this.connected = true; | ||||||
|  |         logger.info(`Redis client connected`); | ||||||
|  |         const version = await this.client.get('schema_version'); | ||||||
|  |         if (version !== this.schemaVersion) { | ||||||
|  |           // schema changed
 | ||||||
|  |           // perform migrations or flush DB if necessary
 | ||||||
|  |           logger.info(`Redis schema version changed from ${version} to ${this.schemaVersion}`); | ||||||
|  |           await this.client.set('schema_version', this.schemaVersion); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $updateBlocks(blocks: BlockExtended[]) { | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       await this.client.set('blocks', JSON.stringify(blocks)); | ||||||
|  |       logger.debug(`Saved latest blocks to Redis cache`); | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to update blocks in Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $updateBlockSummaries(summaries: BlockSummary[]) { | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       await this.client.set('block-summaries', JSON.stringify(summaries)); | ||||||
|  |       logger.debug(`Saved latest block summaries to Redis cache`); | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to update block summaries in Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $addTransaction(tx: MempoolTransactionExtended) { | ||||||
|  |     this.cacheQueue.push(tx); | ||||||
|  |     if (this.cacheQueue.length >= this.txFlushLimit) { | ||||||
|  |       await this.$flushTransactions(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $flushTransactions() { | ||||||
|  |     const success = await this.$addTransactions(this.cacheQueue); | ||||||
|  |     if (success) { | ||||||
|  |       logger.debug(`Saved ${this.cacheQueue.length} transactions to Redis cache`); | ||||||
|  |       this.cacheQueue = []; | ||||||
|  |     } else { | ||||||
|  |       logger.err(`Failed to save ${this.cacheQueue.length} transactions to Redis cache`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private async $addTransactions(newTransactions: MempoolTransactionExtended[]): Promise<boolean> { | ||||||
|  |     if (!newTransactions.length) { | ||||||
|  |       return true; | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       const msetData = newTransactions.map(tx => { | ||||||
|  |         const minified: any = { ...tx }; | ||||||
|  |         delete minified.hex; | ||||||
|  |         for (const vin of minified.vin) { | ||||||
|  |           delete vin.inner_redeemscript_asm; | ||||||
|  |           delete vin.inner_witnessscript_asm; | ||||||
|  |           delete vin.scriptsig_asm; | ||||||
|  |         } | ||||||
|  |         for (const vout of minified.vout) { | ||||||
|  |           delete vout.scriptpubkey_asm; | ||||||
|  |         } | ||||||
|  |         return [`mempool:tx:${tx.txid}`, JSON.stringify(minified)]; | ||||||
|  |       }); | ||||||
|  |       await this.client.MSET(msetData); | ||||||
|  |       return true; | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to add ${newTransactions.length} transactions to Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $removeTransactions(transactions: string[]) { | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       for (let i = 0; i < Math.ceil(transactions.length / 10000); i++) { | ||||||
|  |         const slice = transactions.slice(i * 10000, (i + 1) * 10000); | ||||||
|  |         await this.client.unlink(slice.map(txid => `mempool:tx:${txid}`)); | ||||||
|  |         logger.debug(`Deleted ${slice.length} transactions from the Redis cache`); | ||||||
|  |       } | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to remove ${transactions.length} transactions from Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $setRbfEntry(type: string, txid: string, value: any): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       await this.client.set(`rbf:${type}:${txid}`, JSON.stringify(value)); | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to set RBF ${type} in Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $removeRbfEntry(type: string, txid: string): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       await this.client.unlink(`rbf:${type}:${txid}`); | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to remove RBF ${type} from Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $getBlocks(): Promise<BlockExtended[]> { | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       const json = await this.client.get('blocks'); | ||||||
|  |       return JSON.parse(json); | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to retrieve blocks from Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |       return []; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $getBlockSummaries(): Promise<BlockSummary[]> { | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       const json = await this.client.get('block-summaries'); | ||||||
|  |       return JSON.parse(json); | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to retrieve blocks from Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |       return []; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $getMempool(): Promise<{ [txid: string]: MempoolTransactionExtended }> { | ||||||
|  |     const start = Date.now(); | ||||||
|  |     const mempool = {}; | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       const mempoolList = await this.scanKeys<MempoolTransactionExtended>('mempool:tx:*'); | ||||||
|  |       for (const tx of mempoolList) { | ||||||
|  |         mempool[tx.key] = tx.value; | ||||||
|  |       } | ||||||
|  |       logger.info(`Loaded mempool from Redis cache in ${Date.now() - start} ms`); | ||||||
|  |       return mempool || {}; | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to retrieve mempool from Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |     } | ||||||
|  |     return {}; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $getRbfEntries(type: string): Promise<any[]> { | ||||||
|  |     try { | ||||||
|  |       await this.$ensureConnected(); | ||||||
|  |       const rbfEntries = await this.scanKeys<MempoolTransactionExtended[]>(`rbf:${type}:*`); | ||||||
|  |       return rbfEntries; | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.warn(`Failed to retrieve Rbf ${type}s from Redis cache: ${e instanceof Error ? e.message : e}`); | ||||||
|  |       return []; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async $loadCache() { | ||||||
|  |     logger.info('Restoring mempool and blocks data from Redis cache'); | ||||||
|  |     // Load block data
 | ||||||
|  |     const loadedBlocks = await this.$getBlocks(); | ||||||
|  |     const loadedBlockSummaries = await this.$getBlockSummaries(); | ||||||
|  |     // Load mempool
 | ||||||
|  |     const loadedMempool = await this.$getMempool(); | ||||||
|  |     this.inflateLoadedTxs(loadedMempool); | ||||||
|  |     // Load rbf data
 | ||||||
|  |     const rbfTxs = await this.$getRbfEntries('tx'); | ||||||
|  |     const rbfTrees = await this.$getRbfEntries('tree'); | ||||||
|  |     const rbfExpirations = await this.$getRbfEntries('exp'); | ||||||
|  | 
 | ||||||
|  |     // Set loaded data
 | ||||||
|  |     blocks.setBlocks(loadedBlocks || []); | ||||||
|  |     blocks.setBlockSummaries(loadedBlockSummaries || []); | ||||||
|  |     await memPool.$setMempool(loadedMempool); | ||||||
|  |     await rbfCache.load({ | ||||||
|  |       txs: rbfTxs, | ||||||
|  |       trees: rbfTrees.map(loadedTree => loadedTree.value), | ||||||
|  |       expiring: rbfExpirations, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private inflateLoadedTxs(mempool: { [txid: string]: MempoolTransactionExtended }) { | ||||||
|  |     for (const tx of Object.values(mempool)) { | ||||||
|  |       for (const vin of tx.vin) { | ||||||
|  |         if (vin.scriptsig) { | ||||||
|  |           vin.scriptsig_asm = transactionUtils.convertScriptSigAsm(vin.scriptsig); | ||||||
|  |           transactionUtils.addInnerScriptsToVin(vin); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       for (const vout of tx.vout) { | ||||||
|  |         if (vout.scriptpubkey) { | ||||||
|  |           vout.scriptpubkey_asm = transactionUtils.convertScriptSigAsm(vout.scriptpubkey); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private async scanKeys<T>(pattern): Promise<{ key: string, value: T }[]> { | ||||||
|  |     logger.info(`loading Redis entries for ${pattern}`); | ||||||
|  |     let keys: string[] = []; | ||||||
|  |     const result: { key: string, value: T }[] = []; | ||||||
|  |     const patternLength = pattern.length - 1; | ||||||
|  |     let count = 0; | ||||||
|  |     const processValues = async (keys): Promise<void> => { | ||||||
|  |       const values = await this.client.MGET(keys); | ||||||
|  |       for (let i = 0; i < values.length; i++) { | ||||||
|  |         if (values[i]) { | ||||||
|  |           result.push({ key: keys[i].slice(patternLength), value: JSON.parse(values[i]) }); | ||||||
|  |           count++; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       logger.info(`loaded ${count} entries from Redis cache`); | ||||||
|  |     }; | ||||||
|  |     for await (const key of this.client.scanIterator({ | ||||||
|  |       MATCH: pattern, | ||||||
|  |       COUNT: 100 | ||||||
|  |     })) { | ||||||
|  |       keys.push(key); | ||||||
|  |       if (keys.length >= 10000) { | ||||||
|  |         await processValues(keys); | ||||||
|  |         keys = []; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (keys.length) { | ||||||
|  |       await processValues(keys); | ||||||
|  |     } | ||||||
|  |     return result; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default new RedisCache(); | ||||||
| @ -3,6 +3,7 @@ import { IEsploraApi } from './bitcoin/esplora-api.interface'; | |||||||
| import { Common } from './common'; | import { Common } from './common'; | ||||||
| import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; | import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; | ||||||
| import * as bitcoinjs from 'bitcoinjs-lib'; | import * as bitcoinjs from 'bitcoinjs-lib'; | ||||||
|  | import logger from '../logger'; | ||||||
| 
 | 
 | ||||||
| class TransactionUtils { | class TransactionUtils { | ||||||
|   constructor() { } |   constructor() { } | ||||||
| @ -22,6 +23,23 @@ class TransactionUtils { | |||||||
|     }; |     }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   // Wrapper for $getTransactionExtended with an automatic retry direct to Core if the first API request fails.
 | ||||||
|  |   // Propagates any error from the retry request.
 | ||||||
|  |   public async $getTransactionExtendedRetry(txid: string, addPrevouts = false, lazyPrevouts = false, forceCore = false, addMempoolData = false): Promise<TransactionExtended> { | ||||||
|  |     try { | ||||||
|  |       const result = await this.$getTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore, addMempoolData); | ||||||
|  |       if (result) { | ||||||
|  |         return result; | ||||||
|  |       } else { | ||||||
|  |         logger.err(`Cannot fetch tx ${txid}. Reason: backend returned null data`); | ||||||
|  |       } | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.err(`Cannot fetch tx ${txid}. Reason: ` + (e instanceof Error ? e.message : e)); | ||||||
|  |     } | ||||||
|  |     // retry direct from Core if first request failed
 | ||||||
|  |     return this.$getTransactionExtended(txid, addPrevouts, lazyPrevouts, true, addMempoolData); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * @param txId |    * @param txId | ||||||
|    * @param addPrevouts |    * @param addPrevouts | ||||||
| @ -31,10 +49,17 @@ class TransactionUtils { | |||||||
|   public async $getTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false, addMempoolData = false): Promise<TransactionExtended> { |   public async $getTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false, addMempoolData = false): Promise<TransactionExtended> { | ||||||
|     let transaction: IEsploraApi.Transaction; |     let transaction: IEsploraApi.Transaction; | ||||||
|     if (forceCore === true) { |     if (forceCore === true) { | ||||||
|       transaction  = await bitcoinCoreApi.$getRawTransaction(txId, true); |       transaction  = await bitcoinCoreApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts); | ||||||
|     } else { |     } else { | ||||||
|       transaction  = await bitcoinApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts); |       transaction  = await bitcoinApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     if (Common.isLiquid()) { | ||||||
|  |       if (!isFinite(Number(transaction.fee))) { | ||||||
|  |         transaction.fee = Object.values(transaction.fee || {}).reduce((total, output) => total + output, 0); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     if (addMempoolData || !transaction?.status?.confirmed) { |     if (addMempoolData || !transaction?.status?.confirmed) { | ||||||
|       return this.extendMempoolTransaction(transaction); |       return this.extendMempoolTransaction(transaction); | ||||||
|     } else { |     } else { | ||||||
| @ -46,14 +71,13 @@ class TransactionUtils { | |||||||
|     return (await this.$getTransactionExtended(txId, addPrevouts, lazyPrevouts, forceCore, true)) as MempoolTransactionExtended; |     return (await this.$getTransactionExtended(txId, addPrevouts, lazyPrevouts, forceCore, true)) as MempoolTransactionExtended; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private extendTransaction(transaction: IEsploraApi.Transaction): TransactionExtended { |   public extendTransaction(transaction: IEsploraApi.Transaction): TransactionExtended { | ||||||
|     // @ts-ignore
 |     // @ts-ignore
 | ||||||
|     if (transaction.vsize) { |     if (transaction.vsize) { | ||||||
|       // @ts-ignore
 |       // @ts-ignore
 | ||||||
|       return transaction; |       return transaction; | ||||||
|     } |     } | ||||||
|     const feePerVbytes = Math.max(Common.isLiquid() ? 0.1 : 1, |     const feePerVbytes = (transaction.fee || 0) / (transaction.weight / 4); | ||||||
|       (transaction.fee || 0) / (transaction.weight / 4)); |  | ||||||
|     const transactionExtended: TransactionExtended = Object.assign({ |     const transactionExtended: TransactionExtended = Object.assign({ | ||||||
|       vsize: Math.round(transaction.weight / 4), |       vsize: Math.round(transaction.weight / 4), | ||||||
|       feePerVsize: feePerVbytes, |       feePerVsize: feePerVbytes, | ||||||
| @ -68,13 +92,11 @@ class TransactionUtils { | |||||||
|   public extendMempoolTransaction(transaction: IEsploraApi.Transaction): MempoolTransactionExtended { |   public extendMempoolTransaction(transaction: IEsploraApi.Transaction): MempoolTransactionExtended { | ||||||
|     const vsize = Math.ceil(transaction.weight / 4); |     const vsize = Math.ceil(transaction.weight / 4); | ||||||
|     const fractionalVsize = (transaction.weight / 4); |     const fractionalVsize = (transaction.weight / 4); | ||||||
|     const sigops = this.countSigops(transaction); |     const sigops = !Common.isLiquid() ? this.countSigops(transaction) : 0; | ||||||
|     // https://github.com/bitcoin/bitcoin/blob/e9262ea32a6e1d364fb7974844fadc36f931f8c6/src/policy/policy.cpp#L295-L298
 |     // https://github.com/bitcoin/bitcoin/blob/e9262ea32a6e1d364fb7974844fadc36f931f8c6/src/policy/policy.cpp#L295-L298
 | ||||||
|     const adjustedVsize = Math.max(fractionalVsize, sigops *  5); // adjusted vsize = Max(weight, sigops * bytes_per_sigop) / witness_scale_factor
 |     const adjustedVsize = Math.max(fractionalVsize, sigops *  5); // adjusted vsize = Max(weight, sigops * bytes_per_sigop) / witness_scale_factor
 | ||||||
|     const feePerVbytes = Math.max(Common.isLiquid() ? 0.1 : 1, |     const feePerVbytes = (transaction.fee || 0) / fractionalVsize; | ||||||
|       (transaction.fee || 0) / fractionalVsize); |     const adjustedFeePerVsize = (transaction.fee || 0) / adjustedVsize; | ||||||
|     const adjustedFeePerVsize = Math.max(Common.isLiquid() ? 0.1 : 1, |  | ||||||
|       (transaction.fee || 0) / adjustedVsize); |  | ||||||
|     const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, { |     const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, { | ||||||
|       order: this.txidToOrdering(transaction.txid), |       order: this.txidToOrdering(transaction.txid), | ||||||
|       vsize: Math.round(transaction.weight / 4), |       vsize: Math.round(transaction.weight / 4), | ||||||
| @ -166,6 +188,122 @@ class TransactionUtils { | |||||||
|       16 |       16 | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   public addInnerScriptsToVin(vin: IEsploraApi.Vin): void { | ||||||
|  |     if (!vin.prevout) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (vin.prevout.scriptpubkey_type === 'p2sh') { | ||||||
|  |       const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0]; | ||||||
|  |       vin.inner_redeemscript_asm = this.convertScriptSigAsm(redeemScript); | ||||||
|  |       if (vin.witness && vin.witness.length > 2) { | ||||||
|  |         const witnessScript = vin.witness[vin.witness.length - 1]; | ||||||
|  |         vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) { | ||||||
|  |       const witnessScript = vin.witness[vin.witness.length - 1]; | ||||||
|  |       vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) { | ||||||
|  |       const witnessScript = this.witnessToP2TRScript(vin.witness); | ||||||
|  |       if (witnessScript !== null) { | ||||||
|  |         vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public convertScriptSigAsm(hex: string): string { | ||||||
|  |     const buf = Buffer.from(hex, 'hex'); | ||||||
|  | 
 | ||||||
|  |     const b: string[] = []; | ||||||
|  | 
 | ||||||
|  |     let i = 0; | ||||||
|  |     while (i < buf.length) { | ||||||
|  |       const op = buf[i]; | ||||||
|  |       if (op >= 0x01 && op <= 0x4e) { | ||||||
|  |         i++; | ||||||
|  |         let push: number; | ||||||
|  |         if (op === 0x4c) { | ||||||
|  |           push = buf.readUInt8(i); | ||||||
|  |           b.push('OP_PUSHDATA1'); | ||||||
|  |           i += 1; | ||||||
|  |         } else if (op === 0x4d) { | ||||||
|  |           push = buf.readUInt16LE(i); | ||||||
|  |           b.push('OP_PUSHDATA2'); | ||||||
|  |           i += 2; | ||||||
|  |         } else if (op === 0x4e) { | ||||||
|  |           push = buf.readUInt32LE(i); | ||||||
|  |           b.push('OP_PUSHDATA4'); | ||||||
|  |           i += 4; | ||||||
|  |         } else { | ||||||
|  |           push = op; | ||||||
|  |           b.push('OP_PUSHBYTES_' + push); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const data = buf.slice(i, i + push); | ||||||
|  |         if (data.length !== push) { | ||||||
|  |           break; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         b.push(data.toString('hex')); | ||||||
|  |         i += data.length; | ||||||
|  |       } else { | ||||||
|  |         if (op === 0x00) { | ||||||
|  |           b.push('OP_0'); | ||||||
|  |         } else if (op === 0x4f) { | ||||||
|  |           b.push('OP_PUSHNUM_NEG1'); | ||||||
|  |         } else if (op === 0xb1) { | ||||||
|  |           b.push('OP_CLTV'); | ||||||
|  |         } else if (op === 0xb2) { | ||||||
|  |           b.push('OP_CSV'); | ||||||
|  |         } else if (op === 0xba) { | ||||||
|  |           b.push('OP_CHECKSIGADD'); | ||||||
|  |         } else { | ||||||
|  |           const opcode = bitcoinjs.script.toASM([ op ]); | ||||||
|  |           if (opcode && op < 0xfd) { | ||||||
|  |             if (/^OP_(\d+)$/.test(opcode)) { | ||||||
|  |               b.push(opcode.replace(/^OP_(\d+)$/, 'OP_PUSHNUM_$1')); | ||||||
|  |             } else { | ||||||
|  |               b.push(opcode); | ||||||
|  |             } | ||||||
|  |           } else { | ||||||
|  |             b.push('OP_RETURN_' + op); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         i += 1; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return b.join(' '); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * This function must only be called when we know the witness we are parsing | ||||||
|  |    * is a taproot witness. | ||||||
|  |    * @param witness An array of hex strings that represents the witness stack of | ||||||
|  |    *                the input. | ||||||
|  |    * @returns null if the witness is not a script spend, and the hex string of | ||||||
|  |    *          the script item if it is a script spend. | ||||||
|  |    */ | ||||||
|  |   public witnessToP2TRScript(witness: string[]): string | null { | ||||||
|  |     if (witness.length < 2) return null; | ||||||
|  |     // Note: see BIP341 for parsing details of witness stack
 | ||||||
|  | 
 | ||||||
|  |     // If there are at least two witness elements, and the first byte of the
 | ||||||
|  |     // last element is 0x50, this last element is called annex a and
 | ||||||
|  |     // is removed from the witness stack.
 | ||||||
|  |     const hasAnnex = witness[witness.length - 1].substring(0, 2) === '50'; | ||||||
|  |     // If there are at least two witness elements left, script path spending is used.
 | ||||||
|  |     // Call the second-to-last stack element s, the script.
 | ||||||
|  |     // (Note: this phrasing from BIP341 assumes we've *removed* the annex from the stack)
 | ||||||
|  |     if (hasAnnex && witness.length < 3) return null; | ||||||
|  |     const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2; | ||||||
|  |     return witness[positionOfScript]; | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default new TransactionUtils(); | export default new TransactionUtils(); | ||||||
|  | |||||||
| @ -183,15 +183,25 @@ class WebsocketHandler { | |||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           if (parsedMessage && parsedMessage['track-address']) { |           if (parsedMessage && parsedMessage['track-address']) { | ||||||
|             if (/^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/ |             if (/^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64})$/ | ||||||
|               .test(parsedMessage['track-address'])) { |               .test(parsedMessage['track-address'])) { | ||||||
|               let matchedAddress = parsedMessage['track-address']; |               let matchedAddress = parsedMessage['track-address']; | ||||||
|               if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(parsedMessage['track-address'])) { |               if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(parsedMessage['track-address'])) { | ||||||
|                 matchedAddress = matchedAddress.toLowerCase(); |                 matchedAddress = matchedAddress.toLowerCase(); | ||||||
|               } |               } | ||||||
|  |               if (/^04[a-fA-F0-9]{128}$/.test(parsedMessage['track-address'])) { | ||||||
|  |                 client['track-address'] = null; | ||||||
|  |                 client['track-scriptpubkey'] = '41' + matchedAddress + 'ac'; | ||||||
|  |               } else if (/^|(02|03)[a-fA-F0-9]{64}$/.test(parsedMessage['track-address'])) { | ||||||
|  |                 client['track-address'] = null; | ||||||
|  |                 client['track-scriptpubkey'] = '21' + matchedAddress + 'ac'; | ||||||
|  |               } 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; | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
| @ -546,6 +556,44 @@ class WebsocketHandler { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       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); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       if (client['track-asset']) { |       if (client['track-asset']) { | ||||||
|         const foundTransactions: TransactionExtended[] = []; |         const foundTransactions: TransactionExtended[] = []; | ||||||
| 
 | 
 | ||||||
| @ -604,7 +652,7 @@ class WebsocketHandler { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (client['track-mempool-block'] >= 0) { |       if (client['track-mempool-block'] >= 0 && memPool.isInSync()) { | ||||||
|         const index = client['track-mempool-block']; |         const index = client['track-mempool-block']; | ||||||
|         if (mBlockDeltas[index]) { |         if (mBlockDeltas[index]) { | ||||||
|           response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { |           response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { | ||||||
| @ -644,7 +692,7 @@ class WebsocketHandler { | |||||||
|     memPool.handleMinedRbfTransactions(rbfTransactions); |     memPool.handleMinedRbfTransactions(rbfTransactions); | ||||||
|     memPool.removeFromSpendMap(transactions); |     memPool.removeFromSpendMap(transactions); | ||||||
| 
 | 
 | ||||||
|     if (config.MEMPOOL.AUDIT) { |     if (config.MEMPOOL.AUDIT && memPool.isInSync()) { | ||||||
|       let projectedBlocks; |       let projectedBlocks; | ||||||
|       let auditMempool = _memPool; |       let auditMempool = _memPool; | ||||||
|       // template calculation functions have mempool side effects, so calculate audits using
 |       // template calculation functions have mempool side effects, so calculate audits using
 | ||||||
| @ -665,7 +713,7 @@ class WebsocketHandler { | |||||||
|         projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); |         projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (Common.indexingEnabled() && memPool.isInSync()) { |       if (Common.indexingEnabled()) { | ||||||
|         const { censored, added, fresh, sigop, fullrbf, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool); |         const { censored, added, fresh, sigop, fullrbf, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool); | ||||||
|         const matchRate = Math.round(score * 100 * 100) / 100; |         const matchRate = Math.round(score * 100 * 100) / 100; | ||||||
| 
 | 
 | ||||||
| @ -821,6 +869,33 @@ class WebsocketHandler { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       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) { | ||||||
|  |           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-asset']) { |       if (client['track-asset']) { | ||||||
|         const foundTransactions: TransactionExtended[] = []; |         const foundTransactions: TransactionExtended[] = []; | ||||||
| 
 | 
 | ||||||
| @ -858,7 +933,7 @@ class WebsocketHandler { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (client['track-mempool-block'] >= 0) { |       if (client['track-mempool-block'] >= 0 && memPool.isInSync()) { | ||||||
|         const index = client['track-mempool-block']; |         const index = client['track-mempool-block']; | ||||||
|         if (mBlockDeltas && mBlockDeltas[index]) { |         if (mBlockDeltas && mBlockDeltas[index]) { | ||||||
|           response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { |           response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ interface IConfig { | |||||||
|     API_URL_PREFIX: string; |     API_URL_PREFIX: string; | ||||||
|     POLL_RATE_MS: number; |     POLL_RATE_MS: number; | ||||||
|     CACHE_DIR: string; |     CACHE_DIR: string; | ||||||
|  |     CACHE_ENABLED: boolean; | ||||||
|     CLEAR_PROTECTION_MINUTES: number; |     CLEAR_PROTECTION_MINUTES: number; | ||||||
|     RECOMMENDED_FEE_PERCENTILE: number; |     RECOMMENDED_FEE_PERCENTILE: number; | ||||||
|     BLOCK_WEIGHT_UNITS: number; |     BLOCK_WEIGHT_UNITS: number; | ||||||
| @ -137,7 +138,11 @@ interface IConfig { | |||||||
|     AUDIT: boolean; |     AUDIT: boolean; | ||||||
|     AUDIT_START_HEIGHT: number; |     AUDIT_START_HEIGHT: number; | ||||||
|     SERVERS: string[]; |     SERVERS: string[]; | ||||||
|   } |   }, | ||||||
|  |   REDIS: { | ||||||
|  |     ENABLED: boolean; | ||||||
|  |     UNIX_SOCKET_PATH: string; | ||||||
|  |   }, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const defaults: IConfig = { | const defaults: IConfig = { | ||||||
| @ -150,6 +155,7 @@ const defaults: IConfig = { | |||||||
|     'API_URL_PREFIX': '/api/v1/', |     'API_URL_PREFIX': '/api/v1/', | ||||||
|     'POLL_RATE_MS': 2000, |     'POLL_RATE_MS': 2000, | ||||||
|     'CACHE_DIR': './cache', |     'CACHE_DIR': './cache', | ||||||
|  |     'CACHE_ENABLED': true, | ||||||
|     'CLEAR_PROTECTION_MINUTES': 20, |     'CLEAR_PROTECTION_MINUTES': 20, | ||||||
|     'RECOMMENDED_FEE_PERCENTILE': 50, |     'RECOMMENDED_FEE_PERCENTILE': 50, | ||||||
|     'BLOCK_WEIGHT_UNITS': 4000000, |     'BLOCK_WEIGHT_UNITS': 4000000, | ||||||
| @ -275,7 +281,11 @@ const defaults: IConfig = { | |||||||
|     'AUDIT': false, |     'AUDIT': false, | ||||||
|     'AUDIT_START_HEIGHT': 774000, |     'AUDIT_START_HEIGHT': 774000, | ||||||
|     'SERVERS': [], |     'SERVERS': [], | ||||||
|   } |   }, | ||||||
|  |   'REDIS': { | ||||||
|  |     'ENABLED': false, | ||||||
|  |     'UNIX_SOCKET_PATH': '', | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| class Config implements IConfig { | class Config implements IConfig { | ||||||
| @ -296,6 +306,7 @@ class Config implements IConfig { | |||||||
|   EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER']; |   EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER']; | ||||||
|   MAXMIND: IConfig['MAXMIND']; |   MAXMIND: IConfig['MAXMIND']; | ||||||
|   REPLICATION: IConfig['REPLICATION']; |   REPLICATION: IConfig['REPLICATION']; | ||||||
|  |   REDIS: IConfig['REDIS']; | ||||||
| 
 | 
 | ||||||
|   constructor() { |   constructor() { | ||||||
|     const configs = this.merge(configFromFile, defaults); |     const configs = this.merge(configFromFile, defaults); | ||||||
| @ -316,6 +327,7 @@ class Config implements IConfig { | |||||||
|     this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER; |     this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER; | ||||||
|     this.MAXMIND = configs.MAXMIND; |     this.MAXMIND = configs.MAXMIND; | ||||||
|     this.REPLICATION = configs.REPLICATION; |     this.REPLICATION = configs.REPLICATION; | ||||||
|  |     this.REDIS = configs.REDIS; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   merge = (...objects: object[]): IConfig => { |   merge = (...objects: object[]): IConfig => { | ||||||
|  | |||||||
| @ -41,6 +41,7 @@ import chainTips from './api/chain-tips'; | |||||||
| import { AxiosError } from 'axios'; | import { AxiosError } from 'axios'; | ||||||
| import v8 from 'v8'; | import v8 from 'v8'; | ||||||
| import { formatBytes, getBytesUnit } from './utils/format'; | import { formatBytes, getBytesUnit } from './utils/format'; | ||||||
|  | import redisCache from './api/redis-cache'; | ||||||
| 
 | 
 | ||||||
| class Server { | class Server { | ||||||
|   private wss: WebSocket.Server | undefined; |   private wss: WebSocket.Server | undefined; | ||||||
| @ -122,7 +123,11 @@ class Server { | |||||||
|     await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it
 |     await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it
 | ||||||
|     await syncAssets.syncAssets$(); |     await syncAssets.syncAssets$(); | ||||||
|     if (config.MEMPOOL.ENABLED) { |     if (config.MEMPOOL.ENABLED) { | ||||||
|  |       if (config.MEMPOOL.CACHE_ENABLED) { | ||||||
|         await diskCache.$loadMempoolCache(); |         await diskCache.$loadMempoolCache(); | ||||||
|  |       } else if (config.REDIS.ENABLED) { | ||||||
|  |         await redisCache.$loadCache(); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) { |     if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) { | ||||||
| @ -183,14 +188,15 @@ class Server { | |||||||
|       } |       } | ||||||
|       const newMempool = await bitcoinApi.$getRawMempool(); |       const newMempool = await bitcoinApi.$getRawMempool(); | ||||||
|       const numHandledBlocks = await blocks.$updateBlocks(); |       const numHandledBlocks = await blocks.$updateBlocks(); | ||||||
|  |       const pollRate = config.MEMPOOL.POLL_RATE_MS * (indexer.indexerRunning ? 10 : 1); | ||||||
|       if (numHandledBlocks === 0) { |       if (numHandledBlocks === 0) { | ||||||
|         await memPool.$updateMempool(newMempool); |         await memPool.$updateMempool(newMempool, pollRate); | ||||||
|       } |       } | ||||||
|       indexer.$run(); |       indexer.$run(); | ||||||
| 
 | 
 | ||||||
|       // rerun immediately if we skipped the mempool update, otherwise wait POLL_RATE_MS
 |       // rerun immediately if we skipped the mempool update, otherwise wait POLL_RATE_MS
 | ||||||
|       const elapsed = Date.now() - start; |       const elapsed = Date.now() - start; | ||||||
|       const remainingTime = Math.max(0, config.MEMPOOL.POLL_RATE_MS - elapsed) |       const remainingTime = Math.max(0, pollRate - elapsed); | ||||||
|       setTimeout(this.runMainUpdateLoop.bind(this), numHandledBlocks > 0 ? 0 : remainingTime); |       setTimeout(this.runMainUpdateLoop.bind(this), numHandledBlocks > 0 ? 0 : remainingTime); | ||||||
|       this.backendRetryCount = 0; |       this.backendRetryCount = 0; | ||||||
|     } catch (e: any) { |     } catch (e: any) { | ||||||
|  | |||||||
| @ -1,3 +1,4 @@ | |||||||
|  | import bitcoinApi from '../api/bitcoin/bitcoin-api-factory'; | ||||||
| import { BlockExtended, BlockExtension, BlockPrice, EffectiveFeeStats } from '../mempool.interfaces'; | import { BlockExtended, BlockExtension, BlockPrice, EffectiveFeeStats } from '../mempool.interfaces'; | ||||||
| import DB from '../database'; | import DB from '../database'; | ||||||
| import logger from '../logger'; | import logger from '../logger'; | ||||||
| @ -12,6 +13,7 @@ import config from '../config'; | |||||||
| import chainTips from '../api/chain-tips'; | import chainTips from '../api/chain-tips'; | ||||||
| import blocks from '../api/blocks'; | import blocks from '../api/blocks'; | ||||||
| import BlocksAuditsRepository from './BlocksAuditsRepository'; | import BlocksAuditsRepository from './BlocksAuditsRepository'; | ||||||
|  | import transactionUtils from '../api/transaction-utils'; | ||||||
| 
 | 
 | ||||||
| interface DatabaseBlock { | interface DatabaseBlock { | ||||||
|   id: string; |   id: string; | ||||||
| @ -539,7 +541,7 @@ class BlocksRepository { | |||||||
|    */ |    */ | ||||||
|   public async $getBlocksDifficulty(): Promise<object[]> { |   public async $getBlocksDifficulty(): Promise<object[]> { | ||||||
|     try { |     try { | ||||||
|       const [rows]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(blockTimestamp) as time, height, difficulty FROM blocks`); |       const [rows]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(blockTimestamp) as time, height, difficulty, bits FROM blocks`); | ||||||
|       return rows; |       return rows; | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       logger.err('Cannot get blocks difficulty list from the db. Reason: ' + (e instanceof Error ? e.message : e)); |       logger.err('Cannot get blocks difficulty list from the db. Reason: ' + (e instanceof Error ? e.message : e)); | ||||||
| @ -848,7 +850,7 @@ class BlocksRepository { | |||||||
|    */ |    */ | ||||||
|   public async $getOldestConsecutiveBlock(): Promise<any> { |   public async $getOldestConsecutiveBlock(): Promise<any> { | ||||||
|     try { |     try { | ||||||
|       const [rows]: any = await DB.query(`SELECT height, UNIX_TIMESTAMP(blockTimestamp) as timestamp, difficulty FROM blocks ORDER BY height DESC`); |       const [rows]: any = await DB.query(`SELECT height, UNIX_TIMESTAMP(blockTimestamp) as timestamp, difficulty, bits FROM blocks ORDER BY height DESC`); | ||||||
|       for (let i = 0; i < rows.length - 1; ++i) { |       for (let i = 0; i < rows.length - 1; ++i) { | ||||||
|         if (rows[i].height - rows[i + 1].height > 1) { |         if (rows[i].height - rows[i + 1].height > 1) { | ||||||
|           return rows[i]; |           return rows[i]; | ||||||
| @ -1036,8 +1038,17 @@ class BlocksRepository { | |||||||
|     { |     { | ||||||
|       extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); |       extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); | ||||||
|       if (extras.feePercentiles === null) { |       if (extras.feePercentiles === null) { | ||||||
|  | 
 | ||||||
|  |         let summary; | ||||||
|  |         if (config.MEMPOOL.BACKEND === 'esplora') { | ||||||
|  |           const txs = (await bitcoinApi.$getTxsForBlock(dbBlk.id)).map(tx => transactionUtils.extendTransaction(tx)); | ||||||
|  |           summary = blocks.summarizeBlockTransactions(dbBlk.id, txs); | ||||||
|  |         } else { | ||||||
|  |           // Call Core RPC
 | ||||||
|           const block = await bitcoinClient.getBlock(dbBlk.id, 2); |           const block = await bitcoinClient.getBlock(dbBlk.id, 2); | ||||||
|         const summary = blocks.summarizeBlock(block); |           summary = blocks.summarizeBlock(block); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         await BlocksSummariesRepository.$saveTransactions(dbBlk.height, dbBlk.id, summary.transactions); |         await BlocksSummariesRepository.$saveTransactions(dbBlk.height, dbBlk.id, summary.transactions); | ||||||
|         extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); |         extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); | ||||||
|       } |       } | ||||||
|  | |||||||
							
								
								
									
										3
									
								
								contributors/Czino.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/Czino.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of July 29, 2023. | ||||||
|  | 
 | ||||||
|  | Signed: Czino | ||||||
							
								
								
									
										3
									
								
								contributors/andrewtoth.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/andrewtoth.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of August 2, 2023. | ||||||
|  | 
 | ||||||
|  | Signed: andrewtoth | ||||||
							
								
								
									
										3
									
								
								contributors/bguillaumat.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/bguillaumat.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022. | ||||||
|  | 
 | ||||||
|  | Signed: bguillaumat | ||||||
							
								
								
									
										3
									
								
								contributors/devinbileck.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/devinbileck.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of July 21, 2023. | ||||||
|  | 
 | ||||||
|  | Signed: devinbileck | ||||||
							
								
								
									
										5
									
								
								contributors/fiatjaf.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								contributors/fiatjaf.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | |||||||
|  | I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022. | ||||||
|  | I also regret having ever contributed to this repository since they keep asking me to sign this legalese timewaste things. | ||||||
|  | And finally I don't care about licenses and won't sue anyone over intellectual property, which is a fake statist construct invented by evil lobby lawyers. | ||||||
|  | 
 | ||||||
|  | Signed: fiatjaf | ||||||
							
								
								
									
										3
									
								
								contributors/pedromvpg.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/pedromvpg.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of July 20, 2023. | ||||||
|  | 
 | ||||||
|  | Signed: pedromvpg | ||||||
							
								
								
									
										3
									
								
								contributors/rishkwal.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/rishkwal.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of July 29, 2023. | ||||||
|  | 
 | ||||||
|  | Signed: rishkwal | ||||||
| @ -7,9 +7,10 @@ WORKDIR /build | |||||||
| COPY . . | COPY . . | ||||||
| 
 | 
 | ||||||
| RUN apt-get update | RUN apt-get update | ||||||
| RUN apt-get install -y build-essential python3 pkg-config curl | RUN apt-get install -y build-essential python3 pkg-config curl ca-certificates | ||||||
| 
 | 
 | ||||||
| # Install Rust via rustup | # Install Rust via rustup | ||||||
|  | RUN CPU_ARCH=$(uname -m); if [ "$CPU_ARCH" = "armv7l" ]; then c_rehash; fi | ||||||
| RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable | RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable | ||||||
| ENV PATH="/root/.cargo/bin:$PATH" | ENV PATH="/root/.cargo/bin:$PATH" | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ | |||||||
|     "API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__", |     "API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__", | ||||||
|     "POLL_RATE_MS": __MEMPOOL_POLL_RATE_MS__, |     "POLL_RATE_MS": __MEMPOOL_POLL_RATE_MS__, | ||||||
|     "CACHE_DIR": "__MEMPOOL_CACHE_DIR__", |     "CACHE_DIR": "__MEMPOOL_CACHE_DIR__", | ||||||
|  |     "CACHE_ENABLED": __MEMPOOL_CACHE_ENABLED__, | ||||||
|     "CLEAR_PROTECTION_MINUTES": __MEMPOOL_CLEAR_PROTECTION_MINUTES__, |     "CLEAR_PROTECTION_MINUTES": __MEMPOOL_CLEAR_PROTECTION_MINUTES__, | ||||||
|     "RECOMMENDED_FEE_PERCENTILE": __MEMPOOL_RECOMMENDED_FEE_PERCENTILE__, |     "RECOMMENDED_FEE_PERCENTILE": __MEMPOOL_RECOMMENDED_FEE_PERCENTILE__, | ||||||
|     "BLOCK_WEIGHT_UNITS": __MEMPOOL_BLOCK_WEIGHT_UNITS__, |     "BLOCK_WEIGHT_UNITS": __MEMPOOL_BLOCK_WEIGHT_UNITS__, | ||||||
| @ -133,5 +134,9 @@ | |||||||
|     "AUDIT": __REPLICATION_AUDIT__, |     "AUDIT": __REPLICATION_AUDIT__, | ||||||
|     "AUDIT_START_HEIGHT": __REPLICATION_AUDIT_START_HEIGHT__, |     "AUDIT_START_HEIGHT": __REPLICATION_AUDIT_START_HEIGHT__, | ||||||
|     "SERVERS": __REPLICATION_SERVERS__ |     "SERVERS": __REPLICATION_SERVERS__ | ||||||
|  |   }, | ||||||
|  |   "REDIS": { | ||||||
|  |     "ENABLED": __REDIS_ENABLED__, | ||||||
|  |     "UNIX_SOCKET_PATH": "__REDIS_UNIX_SOCKET_PATH__" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -9,6 +9,7 @@ __MEMPOOL_SPAWN_CLUSTER_PROCS__=${MEMPOOL_SPAWN_CLUSTER_PROCS:=0} | |||||||
| __MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/} | __MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/} | ||||||
| __MEMPOOL_POLL_RATE_MS__=${MEMPOOL_POLL_RATE_MS:=2000} | __MEMPOOL_POLL_RATE_MS__=${MEMPOOL_POLL_RATE_MS:=2000} | ||||||
| __MEMPOOL_CACHE_DIR__=${MEMPOOL_CACHE_DIR:=./cache} | __MEMPOOL_CACHE_DIR__=${MEMPOOL_CACHE_DIR:=./cache} | ||||||
|  | __MEMPOOL_CACHE_ENABLED__=${MEMPOOL_CACHE_ENABLED:=true} | ||||||
| __MEMPOOL_CLEAR_PROTECTION_MINUTES__=${MEMPOOL_CLEAR_PROTECTION_MINUTES:=20} | __MEMPOOL_CLEAR_PROTECTION_MINUTES__=${MEMPOOL_CLEAR_PROTECTION_MINUTES:=20} | ||||||
| __MEMPOOL_RECOMMENDED_FEE_PERCENTILE__=${MEMPOOL_RECOMMENDED_FEE_PERCENTILE:=50} | __MEMPOOL_RECOMMENDED_FEE_PERCENTILE__=${MEMPOOL_RECOMMENDED_FEE_PERCENTILE:=50} | ||||||
| __MEMPOOL_BLOCK_WEIGHT_UNITS__=${MEMPOOL_BLOCK_WEIGHT_UNITS:=4000000} | __MEMPOOL_BLOCK_WEIGHT_UNITS__=${MEMPOOL_BLOCK_WEIGHT_UNITS:=4000000} | ||||||
| @ -136,6 +137,9 @@ __REPLICATION_AUDIT__=${REPLICATION_AUDIT:=true} | |||||||
| __REPLICATION_AUDIT_START_HEIGHT__=${REPLICATION_AUDIT_START_HEIGHT:=774000} | __REPLICATION_AUDIT_START_HEIGHT__=${REPLICATION_AUDIT_START_HEIGHT:=774000} | ||||||
| __REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]} | __REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]} | ||||||
| 
 | 
 | ||||||
|  | # REDIS | ||||||
|  | __REDIS_ENABLED__=${REDIS_ENABLED:=true} | ||||||
|  | __REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=true} | ||||||
| 
 | 
 | ||||||
| mkdir -p "${__MEMPOOL_CACHE_DIR__}" | mkdir -p "${__MEMPOOL_CACHE_DIR__}" | ||||||
| 
 | 
 | ||||||
| @ -147,6 +151,7 @@ sed -i "s!__MEMPOOL_SPAWN_CLUSTER_PROCS__!${__MEMPOOL_SPAWN_CLUSTER_PROCS__}!g" | |||||||
| sed -i "s!__MEMPOOL_API_URL_PREFIX__!${__MEMPOOL_API_URL_PREFIX__}!g" mempool-config.json | sed -i "s!__MEMPOOL_API_URL_PREFIX__!${__MEMPOOL_API_URL_PREFIX__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_POLL_RATE_MS__!${__MEMPOOL_POLL_RATE_MS__}!g" mempool-config.json | sed -i "s!__MEMPOOL_POLL_RATE_MS__!${__MEMPOOL_POLL_RATE_MS__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_CACHE_DIR__!${__MEMPOOL_CACHE_DIR__}!g" mempool-config.json | sed -i "s!__MEMPOOL_CACHE_DIR__!${__MEMPOOL_CACHE_DIR__}!g" mempool-config.json | ||||||
|  | sed -i "s!__MEMPOOL_CACHE_ENABLED__!${__MEMPOOL_CACHE_ENABLED__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_CLEAR_PROTECTION_MINUTES__!${__MEMPOOL_CLEAR_PROTECTION_MINUTES__}!g" mempool-config.json | sed -i "s!__MEMPOOL_CLEAR_PROTECTION_MINUTES__!${__MEMPOOL_CLEAR_PROTECTION_MINUTES__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_RECOMMENDED_FEE_PERCENTILE__!${__MEMPOOL_RECOMMENDED_FEE_PERCENTILE__}!g" mempool-config.json | sed -i "s!__MEMPOOL_RECOMMENDED_FEE_PERCENTILE__!${__MEMPOOL_RECOMMENDED_FEE_PERCENTILE__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_BLOCK_WEIGHT_UNITS__!${__MEMPOOL_BLOCK_WEIGHT_UNITS__}!g" mempool-config.json | sed -i "s!__MEMPOOL_BLOCK_WEIGHT_UNITS__!${__MEMPOOL_BLOCK_WEIGHT_UNITS__}!g" mempool-config.json | ||||||
| @ -165,7 +170,7 @@ sed -i "s!__MEMPOOL_POOLS_JSON_URL__!${__MEMPOOL_POOLS_JSON_URL__}!g" mempool-co | |||||||
| sed -i "s!__MEMPOOL_POOLS_JSON_TREE_URL__!${__MEMPOOL_POOLS_JSON_TREE_URL__}!g" mempool-config.json | sed -i "s!__MEMPOOL_POOLS_JSON_TREE_URL__!${__MEMPOOL_POOLS_JSON_TREE_URL__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_AUDIT__!${__MEMPOOL_AUDIT__}!g" mempool-config.json | sed -i "s!__MEMPOOL_AUDIT__!${__MEMPOOL_AUDIT__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_ADVANCED_GBT_MEMPOOL__!${__MEMPOOL_ADVANCED_GBT_MEMPOOL__}!g" mempool-config.json | sed -i "s!__MEMPOOL_ADVANCED_GBT_MEMPOOL__!${__MEMPOOL_ADVANCED_GBT_MEMPOOL__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_RUST_GBT__!${__MEMPOOL_GBT__}!g" mempool-config.json | sed -i "s!__MEMPOOL_RUST_GBT__!${__MEMPOOL_RUST_GBT__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_ADVANCED_GBT_AUDIT__!${__MEMPOOL_ADVANCED_GBT_AUDIT__}!g" mempool-config.json | sed -i "s!__MEMPOOL_ADVANCED_GBT_AUDIT__!${__MEMPOOL_ADVANCED_GBT_AUDIT__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_CPFP_INDEXING__!${__MEMPOOL_CPFP_INDEXING__}!g" mempool-config.json | sed -i "s!__MEMPOOL_CPFP_INDEXING__!${__MEMPOOL_CPFP_INDEXING__}!g" mempool-config.json | ||||||
| sed -i "s!__MEMPOOL_MAX_BLOCKS_BULK_QUERY__!${__MEMPOOL_MAX_BLOCKS_BULK_QUERY__}!g" mempool-config.json | sed -i "s!__MEMPOOL_MAX_BLOCKS_BULK_QUERY__!${__MEMPOOL_MAX_BLOCKS_BULK_QUERY__}!g" mempool-config.json | ||||||
| @ -262,4 +267,8 @@ sed -i "s!__REPLICATION_AUDIT__!${__REPLICATION_AUDIT__}!g" mempool-config.json | |||||||
| sed -i "s!__REPLICATION_AUDIT_START_HEIGHT__!${__REPLICATION_AUDIT_START_HEIGHT__}!g" mempool-config.json | sed -i "s!__REPLICATION_AUDIT_START_HEIGHT__!${__REPLICATION_AUDIT_START_HEIGHT__}!g" mempool-config.json | ||||||
| sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.json | sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.json | ||||||
| 
 | 
 | ||||||
|  | # REDIS | ||||||
|  | sed -i "s!__REDIS_ENABLED__!${__REDIS_ENABLED__}!g" mempool-config.json | ||||||
|  | sed -i "s!__REDIS_UNIX_SOCKET_PATH__!${__REDIS_UNIX_SOCKET_PATH__}!g" mempool-config.json | ||||||
|  | 
 | ||||||
| node /backend/package/index.js | node /backend/package/index.js | ||||||
|  | |||||||
| @ -18,7 +18,7 @@ fi | |||||||
| 
 | 
 | ||||||
| __TESTNET_ENABLED__=${TESTNET_ENABLED:=false} | __TESTNET_ENABLED__=${TESTNET_ENABLED:=false} | ||||||
| __SIGNET_ENABLED__=${SIGNET_ENABLED:=false} | __SIGNET_ENABLED__=${SIGNET_ENABLED:=false} | ||||||
| __LIQUID_ENABLED__=${LIQUID_EANBLED:=false} | __LIQUID_ENABLED__=${LIQUID_ENABLED:=false} | ||||||
| __LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false} | __LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false} | ||||||
| __BISQ_ENABLED__=${BISQ_ENABLED:=false} | __BISQ_ENABLED__=${BISQ_ENABLED:=false} | ||||||
| __BISQ_SEPARATE_BACKEND__=${BISQ_SEPARATE_BACKEND:=false} | __BISQ_SEPARATE_BACKEND__=${BISQ_SEPARATE_BACKEND:=false} | ||||||
|  | |||||||
| @ -2,11 +2,15 @@ | |||||||
| # For additional information regarding the format and rule options, please see: | # For additional information regarding the format and rule options, please see: | ||||||
| # https://github.com/browserslist/browserslist#queries | # https://github.com/browserslist/browserslist#queries | ||||||
| 
 | 
 | ||||||
|  | # For the full list of supported browsers by the Angular framework, please see: | ||||||
|  | # https://angular.io/guide/browser-support | ||||||
|  | 
 | ||||||
| # You can see what browsers were selected by your queries by running: | # You can see what browsers were selected by your queries by running: | ||||||
| #   npx browserslist | #   npx browserslist | ||||||
| 
 | 
 | ||||||
| > 0.5% | last 2 Chrome versions | ||||||
| last 2 versions | last 1 Firefox version | ||||||
|  | last 2 Edge major versions | ||||||
|  | last 2 Safari major versions | ||||||
|  | last 2 iOS major versions | ||||||
| Firefox ESR | Firefox ESR | ||||||
| not dead |  | ||||||
| not IE 9-11 # For IE 9-11 support, remove 'not'. |  | ||||||
| @ -281,3 +281,15 @@ export function isFeatureActive(network: string, height: number, feature: 'rbf' | |||||||
|     return false; |     return false; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export async function calcScriptHash$(script: string): Promise<string> { | ||||||
|  |   if (!/^[0-9a-fA-F]*$/.test(script) || script.length % 2 !== 0) { | ||||||
|  |     throw new Error('script is not a valid hex string'); | ||||||
|  |   } | ||||||
|  |   const buf = Uint8Array.from(script.match(/.{2}/g).map((byte) => parseInt(byte, 16))); | ||||||
|  |   const hashBuffer = await crypto.subtle.digest('SHA-256', buf); | ||||||
|  |   const hashArray = Array.from(new Uint8Array(hashBuffer)); | ||||||
|  |   return hashArray | ||||||
|  |     .map((bytes) => bytes.toString(16).padStart(2, '0')) | ||||||
|  |     .join(''); | ||||||
|  | } | ||||||
| @ -411,7 +411,7 @@ | |||||||
|       Trademark Notice<br> |       Trademark Notice<br> | ||||||
|     </div> |     </div> | ||||||
|     <p> |     <p> | ||||||
|       The Mempool Open Source Project™, mempool.space™, the mempool logo®, the mempool.space logos™, the mempool square logo®, and the mempool blocks logo™ are either registered trademarks or trademarks of Mempool Space K.K in Japan, the United States, and/or other countries. |       The Mempool Open Source Project®, mempool.space™, the mempool logo®, the mempool.space logos™, the mempool square logo®, and the mempool blocks logo™ are either registered trademarks or trademarks of Mempool Space K.K in Japan, the United States, and/or other countries. | ||||||
|     </p> |     </p> | ||||||
|     <p> |     <p> | ||||||
|       While our software is available under an open source software license, the copyright license does not include an implied right or license to use our trademarks. See our <a href="https://mempool.space/trademark-policy">Trademark Policy and Guidelines</a> for more details, published on <https://mempool.space/trademark-policy>. |       While our software is available under an open source software license, the copyright license does not include an implied right or license to use our trademarks. See our <a href="https://mempool.space/trademark-policy">Trademark Policy and Guidelines</a> for more details, published on <https://mempool.space/trademark-policy>. | ||||||
|  | |||||||
| @ -64,13 +64,15 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { | |||||||
|           this.address = null; |           this.address = null; | ||||||
|           this.addressInfo = null; |           this.addressInfo = null; | ||||||
|           this.addressString = params.get('id') || ''; |           this.addressString = params.get('id') || ''; | ||||||
|           if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(this.addressString)) { |           if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(this.addressString)) { | ||||||
|             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:`); | ||||||
| 
 | 
 | ||||||
|           return this.electrsApiService.getAddress$(this.addressString) |           return (this.addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) | ||||||
|             .pipe( |               ? this.electrsApiService.getPubKeyAddress$(this.addressString) | ||||||
|  |               : this.electrsApiService.getAddress$(this.addressString) | ||||||
|  |             ).pipe( | ||||||
|               catchError((err) => { |               catchError((err) => { | ||||||
|                 this.isLoadingAddress = false; |                 this.isLoadingAddress = false; | ||||||
|                 this.error = err; |                 this.error = err; | ||||||
|  | |||||||
| @ -81,6 +81,7 @@ h1 { | |||||||
|     top: 11px; |     top: 11px; | ||||||
| 	} | 	} | ||||||
|   @media (min-width: 768px) { |   @media (min-width: 768px) { | ||||||
|  |     max-width: calc(100% - 180px); | ||||||
|     top: 17px; |     top: 17px; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; | |||||||
| import { ActivatedRoute, ParamMap } from '@angular/router'; | import { ActivatedRoute, ParamMap } from '@angular/router'; | ||||||
| import { ElectrsApiService } from '../../services/electrs-api.service'; | import { ElectrsApiService } from '../../services/electrs-api.service'; | ||||||
| import { switchMap, filter, catchError, map, tap } from 'rxjs/operators'; | import { switchMap, filter, catchError, map, tap } from 'rxjs/operators'; | ||||||
| import { Address, Transaction } from '../../interfaces/electrs.interface'; | import { Address, ScriptHash, Transaction } from '../../interfaces/electrs.interface'; | ||||||
| import { WebsocketService } from '../../services/websocket.service'; | import { WebsocketService } from '../../services/websocket.service'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
| import { AudioService } from '../../services/audio.service'; | import { AudioService } from '../../services/audio.service'; | ||||||
| @ -72,7 +72,7 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|           this.addressInfo = null; |           this.addressInfo = null; | ||||||
|           document.body.scrollTo(0, 0); |           document.body.scrollTo(0, 0); | ||||||
|           this.addressString = params.get('id') || ''; |           this.addressString = params.get('id') || ''; | ||||||
|           if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(this.addressString)) { |           if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(this.addressString)) { | ||||||
|             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:`); | ||||||
| @ -83,8 +83,11 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|               .pipe(filter((state) => state === 2 && this.transactions && this.transactions.length > 0)) |               .pipe(filter((state) => state === 2 && this.transactions && this.transactions.length > 0)) | ||||||
|           ) |           ) | ||||||
|           .pipe( |           .pipe( | ||||||
|             switchMap(() => this.electrsApiService.getAddress$(this.addressString) |             switchMap(() => ( | ||||||
|               .pipe( |               this.addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) | ||||||
|  |               ? this.electrsApiService.getPubKeyAddress$(this.addressString) | ||||||
|  |               : this.electrsApiService.getAddress$(this.addressString) | ||||||
|  |             ).pipe( | ||||||
|                 catchError((err) => { |                 catchError((err) => { | ||||||
|                   this.isLoadingAddress = false; |                   this.isLoadingAddress = false; | ||||||
|                   this.error = err; |                   this.error = err; | ||||||
| @ -114,7 +117,9 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|           this.updateChainStats(); |           this.updateChainStats(); | ||||||
|           this.isLoadingAddress = false; |           this.isLoadingAddress = false; | ||||||
|           this.isLoadingTransactions = true; |           this.isLoadingTransactions = true; | ||||||
|           return this.electrsApiService.getAddressTransactions$(address.address); |           return address.is_pubkey | ||||||
|  |               ? this.electrsApiService.getScriptHashTransactions$((address.address.length === 66 ? '21' : '41') + address.address + 'ac') | ||||||
|  |               : this.electrsApiService.getAddressTransactions$(address.address); | ||||||
|         }), |         }), | ||||||
|         switchMap((transactions) => { |         switchMap((transactions) => { | ||||||
|           this.tempTransactions = transactions; |           this.tempTransactions = transactions; | ||||||
| @ -161,31 +166,8 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|     this.stateService.mempoolTransactions$ |     this.stateService.mempoolTransactions$ | ||||||
|       .subscribe((transaction) => { |       .subscribe(tx => { | ||||||
|         if (this.transactions.some((t) => t.txid === transaction.txid)) { |         this.addTransaction(tx); | ||||||
|           return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         this.transactions.unshift(transaction); |  | ||||||
|         this.transactions = this.transactions.slice(); |  | ||||||
|         this.txCount++; |  | ||||||
| 
 |  | ||||||
|         if (transaction.vout.some((vout) => vout.scriptpubkey_address === this.address.address)) { |  | ||||||
|           this.audioService.playSound('cha-ching'); |  | ||||||
|         } else { |  | ||||||
|           this.audioService.playSound('chime'); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         transaction.vin.forEach((vin) => { |  | ||||||
|           if (vin.prevout.scriptpubkey_address === this.address.address) { |  | ||||||
|             this.sent += vin.prevout.value; |  | ||||||
|           } |  | ||||||
|         }); |  | ||||||
|         transaction.vout.forEach((vout) => { |  | ||||||
|           if (vout.scriptpubkey_address === this.address.address) { |  | ||||||
|             this.received += vout.value; |  | ||||||
|           } |  | ||||||
|         }); |  | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|     this.stateService.blockTransactions$ |     this.stateService.blockTransactions$ | ||||||
| @ -195,12 +177,47 @@ export class AddressComponent implements OnInit, OnDestroy { | |||||||
|           tx.status = transaction.status; |           tx.status = transaction.status; | ||||||
|           this.transactions = this.transactions.slice(); |           this.transactions = this.transactions.slice(); | ||||||
|           this.audioService.playSound('magic'); |           this.audioService.playSound('magic'); | ||||||
|  |         } else { | ||||||
|  |           if (this.addTransaction(transaction, false)) { | ||||||
|  |             this.audioService.playSound('magic'); | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|         this.totalConfirmedTxCount++; |         this.totalConfirmedTxCount++; | ||||||
|         this.loadedConfirmedTxCount++; |         this.loadedConfirmedTxCount++; | ||||||
|       }); |       }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   addTransaction(transaction: Transaction, playSound: boolean = true): boolean { | ||||||
|  |     if (this.transactions.some((t) => t.txid === transaction.txid)) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.transactions.unshift(transaction); | ||||||
|  |     this.transactions = this.transactions.slice(); | ||||||
|  |     this.txCount++; | ||||||
|  | 
 | ||||||
|  |     if (playSound) { | ||||||
|  |       if (transaction.vout.some((vout) => vout?.scriptpubkey_address === this.address.address)) { | ||||||
|  |         this.audioService.playSound('cha-ching'); | ||||||
|  |       } else { | ||||||
|  |         this.audioService.playSound('chime'); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     transaction.vin.forEach((vin) => { | ||||||
|  |       if (vin?.prevout?.scriptpubkey_address === this.address.address) { | ||||||
|  |         this.sent += vin.prevout.value; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     transaction.vout.forEach((vout) => { | ||||||
|  |       if (vout?.scriptpubkey_address === this.address.address) { | ||||||
|  |         this.received += vout.value; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   loadMore() { |   loadMore() { | ||||||
|     if (this.isLoadingTransactions || !this.totalConfirmedTxCount || this.loadedConfirmedTxCount >= this.totalConfirmedTxCount) { |     if (this.isLoadingTransactions || !this.totalConfirmedTxCount || this.loadedConfirmedTxCount >= this.totalConfirmedTxCount) { | ||||||
|       return; |       return; | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -38,7 +38,7 @@ export default class TxView implements TransactionStripped { | |||||||
|   value: number; |   value: number; | ||||||
|   feerate: number; |   feerate: number; | ||||||
|   rate?: number; |   rate?: number; | ||||||
|   status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf'; |   status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'rbf'; | ||||||
|   context?: 'projected' | 'actual'; |   context?: 'projected' | 'actual'; | ||||||
|   scene?: BlockScene; |   scene?: BlockScene; | ||||||
| 
 | 
 | ||||||
| @ -207,7 +207,7 @@ export default class TxView implements TransactionStripped { | |||||||
|         return auditColors.censored; |         return auditColors.censored; | ||||||
|       case 'missing': |       case 'missing': | ||||||
|       case 'sigop': |       case 'sigop': | ||||||
|       case 'fullrbf': |       case 'rbf': | ||||||
|         return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; |         return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; | ||||||
|       case 'fresh': |       case 'fresh': | ||||||
|       case 'freshcpfp': |       case 'freshcpfp': | ||||||
|  | |||||||
| @ -53,7 +53,7 @@ | |||||||
|           <td *ngSwitchCase="'freshcpfp'"><span class="badge badge-warning" i18n="transaction.audit.recently-cpfped">Recently CPFP'd</span></td> |           <td *ngSwitchCase="'freshcpfp'"><span class="badge badge-warning" i18n="transaction.audit.recently-cpfped">Recently CPFP'd</span></td> | ||||||
|           <td *ngSwitchCase="'added'"><span class="badge badge-warning" i18n="transaction.audit.added">Added</span></td> |           <td *ngSwitchCase="'added'"><span class="badge badge-warning" i18n="transaction.audit.added">Added</span></td> | ||||||
|           <td *ngSwitchCase="'selected'"><span class="badge badge-warning" i18n="transaction.audit.marginal">Marginal fee rate</span></td> |           <td *ngSwitchCase="'selected'"><span class="badge badge-warning" i18n="transaction.audit.marginal">Marginal fee rate</span></td> | ||||||
|           <td *ngSwitchCase="'fullrbf'"><span class="badge badge-warning" i18n="transaction.audit.fullrbf">Full RBF</span></td> |           <td *ngSwitchCase="'rbf'"><span class="badge badge-warning" i18n="transaction.audit.conflicting">Conflicting</span></td> | ||||||
|         </ng-container> |         </ng-container> | ||||||
|       </tr> |       </tr> | ||||||
|     </tbody> |     </tbody> | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -144,11 +144,13 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|         for (const block of blocks) { |         for (const block of blocks) { | ||||||
|           if (block.id === this.blockHash) { |           if (block.id === this.blockHash) { | ||||||
|             this.block = block; |             this.block = block; | ||||||
|  |             if (block.extras) { | ||||||
|               block.extras.minFee = this.getMinBlockFee(block); |               block.extras.minFee = this.getMinBlockFee(block); | ||||||
|               block.extras.maxFee = this.getMaxBlockFee(block); |               block.extras.maxFee = this.getMaxBlockFee(block); | ||||||
|               if (block?.extras?.reward != undefined) { |               if (block?.extras?.reward != undefined) { | ||||||
|                 this.fees = block.extras.reward / 100000000 - this.blockSubsidy; |                 this.fees = block.extras.reward / 100000000 - this.blockSubsidy; | ||||||
|               } |               } | ||||||
|  |             } | ||||||
|           } else if (block.height === this.block?.height) { |           } else if (block.height === this.block?.height) { | ||||||
|             this.block.stale = true; |             this.block.stale = true; | ||||||
|             this.block.canonical = block.id; |             this.block.canonical = block.id; | ||||||
| @ -246,8 +248,10 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|         } |         } | ||||||
|         this.updateAuditAvailableFromBlockHeight(block.height); |         this.updateAuditAvailableFromBlockHeight(block.height); | ||||||
|         this.block = block; |         this.block = block; | ||||||
|  |         if (block.extras) { | ||||||
|           block.extras.minFee = this.getMinBlockFee(block); |           block.extras.minFee = this.getMinBlockFee(block); | ||||||
|           block.extras.maxFee = this.getMaxBlockFee(block); |           block.extras.maxFee = this.getMaxBlockFee(block); | ||||||
|  |         } | ||||||
|         this.blockHeight = block.height; |         this.blockHeight = block.height; | ||||||
|         this.lastBlockHeight = this.blockHeight; |         this.lastBlockHeight = this.blockHeight; | ||||||
|         this.nextBlockHeight = block.height + 1; |         this.nextBlockHeight = block.height + 1; | ||||||
| @ -335,7 +339,7 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|         const isSelected = {}; |         const isSelected = {}; | ||||||
|         const isFresh = {}; |         const isFresh = {}; | ||||||
|         const isSigop = {}; |         const isSigop = {}; | ||||||
|         const isFullRbf = {}; |         const isRbf = {}; | ||||||
|         this.numMissing = 0; |         this.numMissing = 0; | ||||||
|         this.numUnexpected = 0; |         this.numUnexpected = 0; | ||||||
| 
 | 
 | ||||||
| @ -359,7 +363,7 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|             isSigop[txid] = true; |             isSigop[txid] = true; | ||||||
|           } |           } | ||||||
|           for (const txid of blockAudit.fullrbfTxs || []) { |           for (const txid of blockAudit.fullrbfTxs || []) { | ||||||
|             isFullRbf[txid] = true; |             isRbf[txid] = true; | ||||||
|           } |           } | ||||||
|           // set transaction statuses
 |           // set transaction statuses
 | ||||||
|           for (const tx of blockAudit.template) { |           for (const tx of blockAudit.template) { | ||||||
| @ -377,8 +381,8 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|                 } |                 } | ||||||
|               } else if (isSigop[tx.txid]) { |               } else if (isSigop[tx.txid]) { | ||||||
|                 tx.status = 'sigop'; |                 tx.status = 'sigop'; | ||||||
|               } else if (isFullRbf[tx.txid]) { |               } else if (isRbf[tx.txid]) { | ||||||
|                 tx.status = 'fullrbf'; |                 tx.status = 'rbf'; | ||||||
|               } else { |               } else { | ||||||
|                 tx.status = 'missing'; |                 tx.status = 'missing'; | ||||||
|               } |               } | ||||||
| @ -394,8 +398,8 @@ export class BlockComponent implements OnInit, OnDestroy { | |||||||
|               tx.status = 'added'; |               tx.status = 'added'; | ||||||
|             } else if (inTemplate[tx.txid]) { |             } else if (inTemplate[tx.txid]) { | ||||||
|               tx.status = 'found'; |               tx.status = 'found'; | ||||||
|             } else if (isFullRbf[tx.txid]) { |             } else if (isRbf[tx.txid]) { | ||||||
|               tx.status = 'fullrbf'; |               tx.status = 'rbf'; | ||||||
|             } else { |             } else { | ||||||
|               tx.status = 'selected'; |               tx.status = 'selected'; | ||||||
|               isSelected[tx.txid] = true; |               isSelected[tx.txid] = true; | ||||||
|  | |||||||
| @ -113,9 +113,11 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|           const animate = this.chainTip != null && latestHeight > this.chainTip; |           const animate = this.chainTip != null && latestHeight > this.chainTip; | ||||||
| 
 | 
 | ||||||
|           for (const block of blocks) { |           for (const block of blocks) { | ||||||
|  |             if (block?.extras) { | ||||||
|               block.extras.minFee = this.getMinBlockFee(block); |               block.extras.minFee = this.getMinBlockFee(block); | ||||||
|               block.extras.maxFee = this.getMaxBlockFee(block); |               block.extras.maxFee = this.getMaxBlockFee(block); | ||||||
|             } |             } | ||||||
|  |           } | ||||||
| 
 | 
 | ||||||
|           this.blocks = blocks; |           this.blocks = blocks; | ||||||
| 
 | 
 | ||||||
| @ -251,7 +253,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|       if (height >= 0) { |       if (height >= 0) { | ||||||
|         this.cacheService.loadBlock(height); |         this.cacheService.loadBlock(height); | ||||||
|         block = this.cacheService.getCachedBlock(height) || null; |         block = this.cacheService.getCachedBlock(height) || null; | ||||||
|         if (block) { |         if (block?.extras) { | ||||||
|           block.extras.minFee = this.getMinBlockFee(block); |           block.extras.minFee = this.getMinBlockFee(block); | ||||||
|           block.extras.maxFee = this.getMaxBlockFee(block); |           block.extras.maxFee = this.getMaxBlockFee(block); | ||||||
|         } |         } | ||||||
| @ -293,8 +295,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|   onBlockLoaded(block: BlockExtended) { |   onBlockLoaded(block: BlockExtended) { | ||||||
|     const blockIndex = this.height - block.height; |     const blockIndex = this.height - block.height; | ||||||
|     if (blockIndex >= 0 && blockIndex < this.blocks.length) { |     if (blockIndex >= 0 && blockIndex < this.blocks.length) { | ||||||
|  |       if (block?.extras) { | ||||||
|         block.extras.minFee = this.getMinBlockFee(block); |         block.extras.minFee = this.getMinBlockFee(block); | ||||||
|         block.extras.maxFee = this.getMaxBlockFee(block); |         block.extras.maxFee = this.getMaxBlockFee(block); | ||||||
|  |       } | ||||||
|       this.blocks[blockIndex] = block; |       this.blocks[blockIndex] = block; | ||||||
|       this.blockStyles[blockIndex] = this.getStyleForBlock(block, blockIndex); |       this.blockStyles[blockIndex] = this.getStyleForBlock(block, blockIndex); | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -82,9 +82,7 @@ export class BlockchainComponent implements OnInit, OnDestroy { | |||||||
|     } |     } | ||||||
|     this.mempoolOffset = Math.max(0, width - this.dividerOffset); |     this.mempoolOffset = Math.max(0, width - this.dividerOffset); | ||||||
|     this.cd.markForCheck(); |     this.cd.markForCheck(); | ||||||
|     setTimeout(() => { |  | ||||||
|     this.mempoolOffsetChange.emit(this.mempoolOffset); |     this.mempoolOffsetChange.emit(this.mempoolOffset); | ||||||
|     }, 0); |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @HostListener('window:resize', ['$event']) |   @HostListener('window:resize', ['$event']) | ||||||
|  | |||||||
| @ -68,7 +68,7 @@ export class BlocksList implements OnInit { | |||||||
|                   for (const block of blocks) { |                   for (const block of blocks) { | ||||||
|                     // @ts-ignore: Need to add an extra field for the template
 |                     // @ts-ignore: Need to add an extra field for the template
 | ||||||
|                     block.extras.pool.logo = `/resources/mining-pools/` + |                     block.extras.pool.logo = `/resources/mining-pools/` + | ||||||
|                       block.extras.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; |                       block.extras.pool.slug + '.svg'; | ||||||
|                   } |                   } | ||||||
|                 } |                 } | ||||||
|                 if (this.widget) { |                 if (this.widget) { | ||||||
| @ -84,10 +84,10 @@ export class BlocksList implements OnInit { | |||||||
|         .pipe( |         .pipe( | ||||||
|           switchMap((blocks) => { |           switchMap((blocks) => { | ||||||
|             if (blocks[0].height <= this.lastBlockHeight) { |             if (blocks[0].height <= this.lastBlockHeight) { | ||||||
|               return [null]; // Return an empty stream so the last pipe is not executed
 |               return of([]); // Return an empty stream so the last pipe is not executed
 | ||||||
|             } |             } | ||||||
|             this.lastBlockHeight = blocks[0].height; |             this.lastBlockHeight = blocks[0].height; | ||||||
|             return blocks; |             return of(blocks); | ||||||
|           }) |           }) | ||||||
|         ) |         ) | ||||||
|     ]) |     ]) | ||||||
| @ -102,7 +102,7 @@ export class BlocksList implements OnInit { | |||||||
|             if (this.stateService.env.MINING_DASHBOARD) { |             if (this.stateService.env.MINING_DASHBOARD) { | ||||||
|               // @ts-ignore: Need to add an extra field for the template
 |               // @ts-ignore: Need to add an extra field for the template
 | ||||||
|               blocks[1][0].extras.pool.logo = `/resources/mining-pools/` + |               blocks[1][0].extras.pool.logo = `/resources/mining-pools/` + | ||||||
|                 blocks[1][0].extras.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; |                 blocks[1][0].extras.pool.slug + '.svg'; | ||||||
|             } |             } | ||||||
|             acc.unshift(blocks[1][0]); |             acc.unshift(blocks[1][0]); | ||||||
|             acc = acc.slice(0, this.widget ? 6 : 15); |             acc = acc.slice(0, this.widget ? 6 : 15); | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import { ChangeDetectionStrategy, Component, HostListener, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; | ||||||
| import { combineLatest, Observable, timer } from 'rxjs'; | import { combineLatest, Observable, timer } from 'rxjs'; | ||||||
| import { map, switchMap } from 'rxjs/operators'; | import { map, switchMap } from 'rxjs/operators'; | ||||||
| import { StateService } from '../..//services/state.service'; | import { StateService } from '../..//services/state.service'; | ||||||
| @ -61,6 +61,7 @@ export class DifficultyComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     public stateService: StateService, |     public stateService: StateService, | ||||||
|  |     private cd: ChangeDetectorRef, | ||||||
|     @Inject(LOCALE_ID) private locale: string, |     @Inject(LOCALE_ID) private locale: string, | ||||||
|   ) { } |   ) { } | ||||||
| 
 | 
 | ||||||
| @ -189,9 +190,15 @@ export class DifficultyComponent implements OnInit { | |||||||
|     return shapes; |     return shapes; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   @HostListener('pointerdown', ['$event']) | ||||||
|  |   onPointerDown(event) { | ||||||
|  |     this.onPointerMove(event); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   @HostListener('pointermove', ['$event']) |   @HostListener('pointermove', ['$event']) | ||||||
|   onPointerMove(event) { |   onPointerMove(event) { | ||||||
|     this.tooltipPosition = { x: event.clientX, y: event.clientY }; |     this.tooltipPosition = { x: event.clientX, y: event.clientY }; | ||||||
|  |     this.cd.markForCheck(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   onHover(event, rect): void { |   onHover(event, rect): void { | ||||||
|  | |||||||
| @ -74,14 +74,14 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges, OnDestr | |||||||
|     this.labelInterval = this.numSamples / this.numLabels; |     this.labelInterval = this.numSamples / this.numLabels; | ||||||
|     while (nextSample <= maxBlockVSize) { |     while (nextSample <= maxBlockVSize) { | ||||||
|       if (txIndex >= txs.length) { |       if (txIndex >= txs.length) { | ||||||
|         samples.push([(1 - (sampleIndex / this.numSamples)) * 100, 0]); |         samples.push([(1 - (sampleIndex / this.numSamples)) * 100, 0.000001]); | ||||||
|         nextSample += sampleInterval; |         nextSample += sampleInterval; | ||||||
|         sampleIndex++; |         sampleIndex++; | ||||||
|         continue; |         continue; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       while (txs[txIndex] && nextSample < cumVSize + txs[txIndex].vsize) { |       while (txs[txIndex] && nextSample < cumVSize + txs[txIndex].vsize) { | ||||||
|         samples.push([(1 - (sampleIndex / this.numSamples)) * 100, txs[txIndex].rate]); |         samples.push([(1 - (sampleIndex / this.numSamples)) * 100, txs[txIndex].rate || 0.000001]); | ||||||
|         nextSample += sampleInterval; |         nextSample += sampleInterval; | ||||||
|         sampleIndex++; |         sampleIndex++; | ||||||
|       } |       } | ||||||
| @ -118,7 +118,9 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges, OnDestr | |||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       yAxis: { |       yAxis: { | ||||||
|         type: 'value', |         type: 'log', | ||||||
|  |         min: 1, | ||||||
|  |         max: this.data.reduce((min, val) => Math.max(min, val[1]), 1), | ||||||
|         // name: 'Effective Fee Rate s/vb',
 |         // name: 'Effective Fee Rate s/vb',
 | ||||||
|         // nameLocation: 'middle',
 |         // nameLocation: 'middle',
 | ||||||
|         splitLine: { |         splitLine: { | ||||||
| @ -129,12 +131,16 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges, OnDestr | |||||||
|           } |           } | ||||||
|         }, |         }, | ||||||
|         axisLabel: { |         axisLabel: { | ||||||
|  |           show: true, | ||||||
|           formatter: (value: number): string => { |           formatter: (value: number): string => { | ||||||
|             const unitValue = this.weightMode ? value / 4 : value; |             const unitValue = this.weightMode ? value / 4 : value; | ||||||
|             const selectedPowerOfTen = selectPowerOfTen(unitValue); |             const selectedPowerOfTen = selectPowerOfTen(unitValue); | ||||||
|             const newVal = Math.round(unitValue / selectedPowerOfTen.divider); |             const newVal = Math.round(unitValue / selectedPowerOfTen.divider); | ||||||
|             return `${newVal}${selectedPowerOfTen.unit}`; |             return `${newVal}${selectedPowerOfTen.unit}`; | ||||||
|           }, |           }, | ||||||
|  |         }, | ||||||
|  |         axisTick: { | ||||||
|  |           show: true, | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|       series: [{ |       series: [{ | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -64,7 +64,9 @@ li.nav-item { | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| .navbar-collapse { | .navbar-collapse { | ||||||
|  |   @media (min-width: 564px) { | ||||||
|     flex-basis: auto; |     flex-basis: auto; | ||||||
|  |   } | ||||||
|   justify-content: flex-end; |   justify-content: flex-end; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -31,6 +31,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|   @Input() getHref?: (index) => string = (index) => `/mempool-block/${index}`; |   @Input() getHref?: (index) => string = (index) => `/mempool-block/${index}`; | ||||||
|   @Input() allBlocks: boolean = false; |   @Input() allBlocks: boolean = false; | ||||||
| 
 | 
 | ||||||
|  |   mempoolWidth: number = 0; | ||||||
|   @Output() widthChange: EventEmitter<number> = new EventEmitter(); |   @Output() widthChange: EventEmitter<number> = new EventEmitter(); | ||||||
| 
 | 
 | ||||||
|   specialBlocks = specialBlocks; |   specialBlocks = specialBlocks; | ||||||
| @ -49,6 +50,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|   blockSubscription: Subscription; |   blockSubscription: Subscription; | ||||||
|   networkSubscription: Subscription; |   networkSubscription: Subscription; | ||||||
|   chainTipSubscription: Subscription; |   chainTipSubscription: Subscription; | ||||||
|  |   keySubscription: Subscription; | ||||||
|  |   isTabHiddenSubscription: Subscription; | ||||||
|   network = ''; |   network = ''; | ||||||
|   now = new Date().getTime(); |   now = new Date().getTime(); | ||||||
|   timeOffset = 0; |   timeOffset = 0; | ||||||
| @ -115,8 +118,15 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|       this.calculateTransactionPosition(); |       this.calculateTransactionPosition(); | ||||||
|     }); |     }); | ||||||
|     this.reduceMempoolBlocksToFitScreen(this.mempoolBlocks); |     this.reduceMempoolBlocksToFitScreen(this.mempoolBlocks); | ||||||
|     this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); |     this.isTabHiddenSubscription = this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); | ||||||
|     this.loadingBlocks$ = this.stateService.isLoadingWebSocket$; |     this.loadingBlocks$ = combineLatest([ | ||||||
|  |       this.stateService.isLoadingWebSocket$, | ||||||
|  |       this.stateService.isLoadingMempool$ | ||||||
|  |     ]).pipe( | ||||||
|  |       switchMap(([loadingBlocks, loadingMempool]) => { | ||||||
|  |         return of(loadingBlocks || loadingMempool); | ||||||
|  |       }) | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     this.mempoolBlocks$ = merge( |     this.mempoolBlocks$ = merge( | ||||||
|       of(true), |       of(true), | ||||||
| @ -155,7 +165,11 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|         }), |         }), | ||||||
|         tap(() => { |         tap(() => { | ||||||
|           this.cd.markForCheck(); |           this.cd.markForCheck(); | ||||||
|           this.widthChange.emit(this.containerOffset + this.mempoolBlocks.length * this.blockOffset); |           const width = this.containerOffset + this.mempoolBlocks.length * this.blockOffset; | ||||||
|  |           if (this.mempoolWidth !== width) { | ||||||
|  |             this.mempoolWidth = width; | ||||||
|  |             this.widthChange.emit(this.mempoolWidth); | ||||||
|  |           } | ||||||
|         }) |         }) | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
| @ -212,7 +226,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|     this.networkSubscription = this.stateService.networkChanged$ |     this.networkSubscription = this.stateService.networkChanged$ | ||||||
|       .subscribe((network) => this.network = network); |       .subscribe((network) => this.network = network); | ||||||
| 
 | 
 | ||||||
|     this.stateService.keyNavigation$.subscribe((event) => { |     this.keySubscription = this.stateService.keyNavigation$.subscribe((event) => { | ||||||
|       if (this.markIndex === undefined) { |       if (this.markIndex === undefined) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
| @ -223,13 +237,12 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|         if (this.mempoolBlocks[this.markIndex - 1]) { |         if (this.mempoolBlocks[this.markIndex - 1]) { | ||||||
|           this.router.navigate([this.relativeUrlPipe.transform('mempool-block/'), this.markIndex - 1]); |           this.router.navigate([this.relativeUrlPipe.transform('mempool-block/'), this.markIndex - 1]); | ||||||
|         } else { |         } else { | ||||||
|           this.stateService.blocks$ |           const blocks = this.stateService.blocksSubject$.getValue(); | ||||||
|             .pipe(map((blocks) => blocks[0])) |           for (const block of (blocks || [])) { | ||||||
|             .subscribe((block) => { |  | ||||||
|             if (this.stateService.latestBlockHeight === block.height) { |             if (this.stateService.latestBlockHeight === block.height) { | ||||||
|               this.router.navigate([this.relativeUrlPipe.transform('/block/'), block.id], { state: { data: { block } }}); |               this.router.navigate([this.relativeUrlPipe.transform('/block/'), block.id], { state: { data: { block } }}); | ||||||
|             } |             } | ||||||
|             }); |           } | ||||||
|         } |         } | ||||||
|       } else if (event.key === nextKey) { |       } else if (event.key === nextKey) { | ||||||
|         if (this.mempoolBlocks[this.markIndex + 1]) { |         if (this.mempoolBlocks[this.markIndex + 1]) { | ||||||
| @ -253,6 +266,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | |||||||
|     this.networkSubscription.unsubscribe(); |     this.networkSubscription.unsubscribe(); | ||||||
|     this.timeLtrSubscription.unsubscribe(); |     this.timeLtrSubscription.unsubscribe(); | ||||||
|     this.chainTipSubscription.unsubscribe(); |     this.chainTipSubscription.unsubscribe(); | ||||||
|  |     this.keySubscription.unsubscribe(); | ||||||
|  |     this.isTabHiddenSubscription.unsubscribe(); | ||||||
|     clearTimeout(this.resetTransitionTimeout); |     clearTimeout(this.resetTransitionTimeout); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -51,7 +51,7 @@ | |||||||
|           <a class="title-link" href="" [routerLink]="['/blocks' | relativeUrl]"> |           <a class="title-link" href="" [routerLink]="['/blocks' | relativeUrl]"> | ||||||
|             <h5 class="card-title d-inline" i18n="dashboard.latest-blocks">Latest blocks</h5> |             <h5 class="card-title d-inline" i18n="dashboard.latest-blocks">Latest blocks</h5> | ||||||
|             <span> </span> |             <span> </span> | ||||||
|             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> |             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: #4a68b9"></fa-icon> | ||||||
|           </a> |           </a> | ||||||
|           <app-blocks-list [attr.data-cy]="'latest-blocks'" [widget]=true></app-blocks-list> |           <app-blocks-list [attr.data-cy]="'latest-blocks'" [widget]=true></app-blocks-list> | ||||||
|         </div> |         </div> | ||||||
| @ -65,7 +65,7 @@ | |||||||
|           <a class="title-link" href="" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]"> |           <a class="title-link" href="" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]"> | ||||||
|             <h5 class="card-title d-inline" i18n="dashboard.adjustments">Adjustments</h5> |             <h5 class="card-title d-inline" i18n="dashboard.adjustments">Adjustments</h5> | ||||||
|             <span> </span> |             <span> </span> | ||||||
|             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> |             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: #4a68b9"></fa-icon> | ||||||
|           </a> |           </a> | ||||||
|           <app-difficulty-adjustments-table [attr.data-cy]="'difficulty-adjustments-table'"></app-difficulty-adjustments-table> |           <app-difficulty-adjustments-table [attr.data-cy]="'difficulty-adjustments-table'"></app-difficulty-adjustments-table> | ||||||
|         </div> |         </div> | ||||||
|  | |||||||
| @ -1,6 +1,8 @@ | |||||||
| import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | ||||||
| import { SeoService } from '../../services/seo.service'; | import { SeoService } from '../../services/seo.service'; | ||||||
| import { WebsocketService } from '../../services/websocket.service'; | import { WebsocketService } from '../../services/websocket.service'; | ||||||
|  | import { StateService } from '../../services/state.service'; | ||||||
|  | import { EventType, NavigationStart, Router } from '@angular/router'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-mining-dashboard', |   selector: 'app-mining-dashboard', | ||||||
| @ -8,10 +10,12 @@ import { WebsocketService } from '../../services/websocket.service'; | |||||||
|   styleUrls: ['./mining-dashboard.component.scss'], |   styleUrls: ['./mining-dashboard.component.scss'], | ||||||
|   changeDetection: ChangeDetectionStrategy.OnPush, |   changeDetection: ChangeDetectionStrategy.OnPush, | ||||||
| }) | }) | ||||||
| export class MiningDashboardComponent implements OnInit { | export class MiningDashboardComponent implements OnInit, AfterViewInit { | ||||||
|   constructor( |   constructor( | ||||||
|     private seoService: SeoService, |     private seoService: SeoService, | ||||||
|     private websocketService: WebsocketService, |     private websocketService: WebsocketService, | ||||||
|  |     private stateService: StateService, | ||||||
|  |     private router: Router | ||||||
|   ) { |   ) { | ||||||
|     this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Mining Dashboard`); |     this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Mining Dashboard`); | ||||||
|   } |   } | ||||||
| @ -19,4 +23,15 @@ export class MiningDashboardComponent implements OnInit { | |||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.websocketService.want(['blocks', 'mempool-blocks', 'stats']); |     this.websocketService.want(['blocks', 'mempool-blocks', 'stats']); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   ngAfterViewInit(): void { | ||||||
|  |     this.stateService.focusSearchInputDesktop(); | ||||||
|  |     this.router.events.subscribe((e: 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
 | ||||||
|  |           this.stateService.focusSearchInputDesktop();  | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -139,6 +139,8 @@ | |||||||
|           <td class="" *ngIf="this.miningWindowPreference === '24h'"><b>{{ miningStats.lastEstimatedHashrate}} {{ |           <td class="" *ngIf="this.miningWindowPreference === '24h'"><b>{{ miningStats.lastEstimatedHashrate}} {{ | ||||||
|               miningStats.miningUnits.hashrateUnit }}</b></td> |               miningStats.miningUnits.hashrateUnit }}</b></td> | ||||||
|           <td class=""><b>{{ miningStats.blockCount }}</b></td> |           <td class=""><b>{{ miningStats.blockCount }}</b></td> | ||||||
|  |           <td *ngIf="auditAvailable"></td> | ||||||
|  |           <td *ngIf="auditAvailable"></td> | ||||||
|           <td class="d-none d-md-table-cell"><b>{{ miningStats.totalEmptyBlock }} ({{ miningStats.totalEmptyBlockRatio |           <td class="d-none d-md-table-cell"><b>{{ miningStats.totalEmptyBlock }} ({{ miningStats.totalEmptyBlockRatio | ||||||
|               }}%)</b></td> |               }}%)</b></td> | ||||||
|         </tr> |         </tr> | ||||||
|  | |||||||
| @ -89,7 +89,7 @@ export class PoolPreviewComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|           this.openGraphService.waitOver('pool-stats-' + this.slug); |           this.openGraphService.waitOver('pool-stats-' + this.slug); | ||||||
| 
 | 
 | ||||||
|           const logoSrc = `/resources/mining-pools/` + poolStats.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; |           const logoSrc = `/resources/mining-pools/` + poolStats.pool.slug + '.svg'; | ||||||
|           if (logoSrc === this.lastImgSrc) { |           if (logoSrc === this.lastImgSrc) { | ||||||
|             this.openGraphService.waitOver('pool-img-' + this.slug); |             this.openGraphService.waitOver('pool-img-' + this.slug); | ||||||
|           } |           } | ||||||
|  | |||||||
| @ -79,7 +79,7 @@ export class PoolComponent implements OnInit { | |||||||
|           poolStats.pool.regexes = regexes.slice(0, -3); |           poolStats.pool.regexes = regexes.slice(0, -3); | ||||||
| 
 | 
 | ||||||
|           return Object.assign({ |           return Object.assign({ | ||||||
|             logo: `/resources/mining-pools/` + poolStats.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg' |             logo: `/resources/mining-pools/` + poolStats.pool.slug + '.svg' | ||||||
|           }, poolStats); |           }, poolStats); | ||||||
|         }) |         }) | ||||||
|       ); |       ); | ||||||
|  | |||||||
| @ -43,7 +43,7 @@ | |||||||
| 
 | 
 | ||||||
|       <h4>TRUST YOUR OWN SELF-HOSTED MEMPOOL EXPLORER</h4> |       <h4>TRUST YOUR OWN SELF-HOSTED MEMPOOL EXPLORER</h4> | ||||||
| 
 | 
 | ||||||
|       <p>For maximum privacy, we recommend that you use your own self-hosted instance of The Mempool Open Source Project™ on your own hardware. You can easily install your own self-hosted instance of this website on a Raspberry Pi using a one-click installation method maintained by various Bitcoin fullnode distributions such as Umbrel, RaspiBlitz, MyNode, and RoninDojo. See our project's GitHub page for more details about self-hosting this website.</p> |       <p>For maximum privacy, we recommend that you use your own self-hosted instance of The Mempool Open Source Project® on your own hardware. You can easily install your own self-hosted instance of this website on a Raspberry Pi using a one-click installation method maintained by various Bitcoin fullnode distributions such as Umbrel, RaspiBlitz, MyNode, and RoninDojo. See our project's GitHub page for more details about self-hosting this website.</p> | ||||||
| 
 | 
 | ||||||
|       <br> |       <br> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| <form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate> | <form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate> | ||||||
|   <div class="d-flex"> |   <div class="d-flex"> | ||||||
|     <div class="search-box-container mr-2"> |     <div class="search-box-container mr-2"> | ||||||
|       <input autofocus (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="Explore the full Bitcoin ecosystem"> |       <input #searchInput (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="Explore the full Bitcoin ecosystem"> | ||||||
|       <app-search-results #searchResults [hidden]="dropdownHidden" [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results> |       <app-search-results #searchResults [hidden]="dropdownHidden" [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results> | ||||||
|     </div> |     </div> | ||||||
|     <div> |     <div> | ||||||
|  | |||||||
| @ -18,9 +18,10 @@ | |||||||
| 
 | 
 | ||||||
| form { | form { | ||||||
|   margin-top: 5px; |   margin-top: 5px; | ||||||
|   @media (min-width: 576px) { |   @media (min-width: 564px) { | ||||||
|     margin-top: 0px; |     margin-top: 0px; | ||||||
|     margin-left: 8px; |     margin-left: 5px; | ||||||
|  |     margin-right: -5px; | ||||||
|   } |   } | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     width: 100%; |     width: 100%; | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener, ElementRef } from '@angular/core'; | import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener, ElementRef } from '@angular/core'; | ||||||
| import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; | import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; | ||||||
| import { Router } from '@angular/router'; | import { EventType, NavigationStart, Router } from '@angular/router'; | ||||||
| import { AssetsService } from '../../services/assets.service'; | import { AssetsService } from '../../services/assets.service'; | ||||||
| import { StateService } from '../../services/state.service'; | import { StateService } from '../../services/state.service'; | ||||||
| import { Observable, of, Subject, zip, BehaviorSubject, combineLatest } from 'rxjs'; | import { Observable, of, Subject, zip, BehaviorSubject, combineLatest } from 'rxjs'; | ||||||
| @ -34,7 +34,7 @@ export class SearchFormComponent implements OnInit { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59})$/; |   regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64})$/; | ||||||
|   regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; |   regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; | ||||||
|   regexTransaction = /^([a-fA-F0-9]{64})(:\d+)?$/; |   regexTransaction = /^([a-fA-F0-9]{64})(:\d+)?$/; | ||||||
|   regexBlockheight = /^[0-9]{1,9}$/; |   regexBlockheight = /^[0-9]{1,9}$/; | ||||||
| @ -47,6 +47,8 @@ export class SearchFormComponent implements OnInit { | |||||||
|     this.handleKeyDown($event); |     this.handleKeyDown($event); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   @ViewChild('searchInput') searchInput: ElementRef; | ||||||
|  | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     private formBuilder: UntypedFormBuilder, |     private formBuilder: UntypedFormBuilder, | ||||||
|     private router: Router, |     private router: Router, | ||||||
| @ -55,12 +57,27 @@ export class SearchFormComponent implements OnInit { | |||||||
|     private electrsApiService: ElectrsApiService, |     private electrsApiService: ElectrsApiService, | ||||||
|     private apiService: ApiService, |     private apiService: ApiService, | ||||||
|     private relativeUrlPipe: RelativeUrlPipe, |     private relativeUrlPipe: RelativeUrlPipe, | ||||||
|     private elementRef: ElementRef, |     private elementRef: ElementRef | ||||||
|   ) { } |   ) { | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.stateService.networkChanged$.subscribe((network) => this.network = network); |     this.stateService.networkChanged$.subscribe((network) => this.network = network); | ||||||
|      |      | ||||||
|  |     this.router.events.subscribe((e: NavigationStart) => { // Reset search focus when changing page
 | ||||||
|  |       if (this.searchInput && e.type === EventType.NavigationStart) { | ||||||
|  |         this.searchInput.nativeElement.blur(); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     this.stateService.searchFocus$.subscribe(() => { | ||||||
|  |       if (!this.searchInput) { // Try again a bit later once the view is properly initialized
 | ||||||
|  |         setTimeout(() => this.searchInput.nativeElement.focus(), 100); | ||||||
|  |       } else if (this.searchInput) { | ||||||
|  |         this.searchInput.nativeElement.focus(); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     this.searchForm = this.formBuilder.group({ |     this.searchForm = this.formBuilder.group({ | ||||||
|       searchText: ['', Validators.required], |       searchText: ['', Validators.required], | ||||||
|     }); |     }); | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild, Input } from '@angular/core'; | import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild, Input, DoCheck } from '@angular/core'; | ||||||
| import { Subscription } from 'rxjs'; | import { Subscription } from 'rxjs'; | ||||||
| import { MarkBlockState, StateService } from '../../services/state.service'; | import { MarkBlockState, StateService } from '../../services/state.service'; | ||||||
| import { specialBlocks } from '../../app.constants'; | import { specialBlocks } from '../../app.constants'; | ||||||
| @ -9,7 +9,7 @@ import { BlockExtended } from '../../interfaces/node-api.interface'; | |||||||
|   templateUrl: './start.component.html', |   templateUrl: './start.component.html', | ||||||
|   styleUrls: ['./start.component.scss'], |   styleUrls: ['./start.component.scss'], | ||||||
| }) | }) | ||||||
| export class StartComponent implements OnInit, OnDestroy { | export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||||
|   @Input() showLoadingIndicator = false; |   @Input() showLoadingIndicator = false; | ||||||
| 
 | 
 | ||||||
|   interval = 60; |   interval = 60; | ||||||
| @ -43,6 +43,7 @@ export class StartComponent implements OnInit, OnDestroy { | |||||||
|   pageIndex: number = 0; |   pageIndex: number = 0; | ||||||
|   pages: any[] = []; |   pages: any[] = []; | ||||||
|   pendingMark: number | null = null; |   pendingMark: number | null = null; | ||||||
|  |   pendingOffset: number | null = null; | ||||||
|   lastUpdate: number = 0; |   lastUpdate: number = 0; | ||||||
|   lastMouseX: number; |   lastMouseX: number; | ||||||
|   velocity: number = 0; |   velocity: number = 0; | ||||||
| @ -54,6 +55,14 @@ export class StartComponent implements OnInit, OnDestroy { | |||||||
|     this.isiOS = ['iPhone','iPod','iPad'].includes((navigator as any)?.userAgentData?.platform || navigator.platform); |     this.isiOS = ['iPhone','iPod','iPad'].includes((navigator as any)?.userAgentData?.platform || navigator.platform); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   ngDoCheck(): void { | ||||||
|  |     if (this.pendingOffset != null) { | ||||||
|  |       const offset = this.pendingOffset; | ||||||
|  |       this.pendingOffset = null; | ||||||
|  |       this.addConvertedScrollOffset(offset); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   ngOnInit() { |   ngOnInit() { | ||||||
|     this.firstPageWidth = 40 + (this.blockWidth * this.dynamicBlocksAmount); |     this.firstPageWidth = 40 + (this.blockWidth * this.dynamicBlocksAmount); | ||||||
|     this.blockCounterSubscription = this.stateService.blocks$.subscribe((blocks) => { |     this.blockCounterSubscription = this.stateService.blocks$.subscribe((blocks) => { | ||||||
| @ -429,6 +438,7 @@ export class StartComponent implements OnInit, OnDestroy { | |||||||
| 
 | 
 | ||||||
|   addConvertedScrollOffset(offset: number): void { |   addConvertedScrollOffset(offset: number): void { | ||||||
|     if (!this.blockchainContainer?.nativeElement) { |     if (!this.blockchainContainer?.nativeElement) { | ||||||
|  |       this.pendingOffset = offset; | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     if (this.timeLtr) { |     if (this.timeLtr) { | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ | |||||||
| 
 | 
 | ||||||
|   <div *ngIf="officialMempoolSpace"> |   <div *ngIf="officialMempoolSpace"> | ||||||
|     <h2>Trademark Policy and Guidelines</h2> |     <h2>Trademark Policy and Guidelines</h2> | ||||||
|     <h5>The Mempool Open Source Project ™</h5> |     <h5>The Mempool Open Source Project ®</h5> | ||||||
|     <h6>Updated: July 19, 2021</h6> |     <h6>Updated: July 19, 2021</h6> | ||||||
|     <br> |     <br> | ||||||
| 
 | 
 | ||||||
| @ -304,7 +304,7 @@ | |||||||
| 
 | 
 | ||||||
|           <p>Also, if you are using our Marks in a way described in the sections "Uses for Which We Are Granting a License," you must include the following trademark attribution at the foot of the webpage where you have used the Mark (or, if in a book, on the credits page), on any packaging or labeling, and on advertising or marketing materials:</p> |           <p>Also, if you are using our Marks in a way described in the sections "Uses for Which We Are Granting a License," you must include the following trademark attribution at the foot of the webpage where you have used the Mark (or, if in a book, on the credits page), on any packaging or labeling, and on advertising or marketing materials:</p> | ||||||
| 
 | 
 | ||||||
|           <p>“The Mempool Space K.K.™, The Mempool Open Source Project™, mempool.space™, the mempool logo®, the mempool.space logos™, the mempool square logo®, and the mempool blocks logo™ are either registered trademarks or trademarks of Mempool Space K.K in Japan, the United States, and/or other countries, and are used with permission. Mempool Space K.K. has no affiliation with and does not sponsor or endorse the information provided herein.”</p> |           <p>“The Mempool Space K.K.™, The Mempool Open Source Project®, mempool.space™, the mempool logo®, the mempool.space logos™, the mempool square logo®, and the mempool blocks logo™ are either registered trademarks or trademarks of Mempool Space K.K in Japan, the United States, and/or other countries, and are used with permission. Mempool Space K.K. has no affiliation with and does not sponsor or endorse the information provided herein.”</p> | ||||||
| 
 | 
 | ||||||
|           <li>What to Do When You See Abuse</li> |           <li>What to Do When You See Abuse</li> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -23,7 +23,7 @@ | |||||||
|             <ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx.vin.slice(0, getVinLimit(tx))" [ngForTrackBy]="trackByIndexFn"> |             <ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx.vin.slice(0, getVinLimit(tx))" [ngForTrackBy]="trackByIndexFn"> | ||||||
|               <tr [ngClass]="{ |               <tr [ngClass]="{ | ||||||
|                 'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex, |                 'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex, | ||||||
|                 'highlight': vin.prevout?.scriptpubkey_address === this.address && this.address !== '' |                 'highlight': this.address !== '' && (vin.prevout?.scriptpubkey_address === this.address || (vin.prevout?.scriptpubkey_type === 'p2pk' && vin.prevout?.scriptpubkey.slice(2, -2) === this.address)) | ||||||
|               }"> |               }"> | ||||||
|                 <td class="arrow-td"> |                 <td class="arrow-td"> | ||||||
|                   <ng-template [ngIf]="vin.prevout === null && !vin.is_pegin" [ngIfElse]="hasPrevout"> |                   <ng-template [ngIf]="vin.prevout === null && !vin.is_pegin" [ngIfElse]="hasPrevout"> | ||||||
| @ -56,7 +56,9 @@ | |||||||
|                       <span i18n="transactions-list.peg-in">Peg-in</span> |                       <span i18n="transactions-list.peg-in">Peg-in</span> | ||||||
|                     </ng-container> |                     </ng-container> | ||||||
|                     <ng-container *ngSwitchCase="vin.prevout && vin.prevout.scriptpubkey_type === 'p2pk'"> |                     <ng-container *ngSwitchCase="vin.prevout && vin.prevout.scriptpubkey_type === 'p2pk'"> | ||||||
|                       <span>P2PK</span> |                       <span>P2PK <a class="address p2pk-address" [routerLink]="['/address/' | relativeUrl, vin.prevout.scriptpubkey.slice(2, -2)]" title="{{ vin.prevout.scriptpubkey.slice(2, -2) }}"> | ||||||
|  |                         <app-truncate [text]="vin.prevout.scriptpubkey.slice(2, -2)" [lastChars]="8"></app-truncate> | ||||||
|  |                       </a></span> | ||||||
|                     </ng-container> |                     </ng-container> | ||||||
|                     <ng-container *ngSwitchDefault> |                     <ng-container *ngSwitchDefault> | ||||||
|                       <ng-template [ngIf]="!vin.prevout" [ngIfElse]="defaultAddress"> |                       <ng-template [ngIf]="!vin.prevout" [ngIfElse]="defaultAddress"> | ||||||
| @ -182,12 +184,19 @@ | |||||||
|             <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx.vout.slice(0, getVoutLimit(tx))" [ngForTrackBy]="trackByIndexFn"> |             <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx.vout.slice(0, getVoutLimit(tx))" [ngForTrackBy]="trackByIndexFn"> | ||||||
|               <tr [ngClass]="{ |               <tr [ngClass]="{ | ||||||
|                 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, |                 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, | ||||||
|                 'highlight': vout.scriptpubkey_address === this.address && this.address !== '' |                 'highlight': this.address !== '' && (vout.scriptpubkey_address === this.address || (vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey.slice(2, -2) === this.address)) | ||||||
|               }"> |               }"> | ||||||
|                 <td class="address-cell"> |                 <td class="address-cell"> | ||||||
|                   <a class="address" *ngIf="vout.scriptpubkey_address; else scriptpubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}"> |                   <a class="address" *ngIf="vout.scriptpubkey_address; else pubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}"> | ||||||
|                     <app-truncate [text]="vout.scriptpubkey_address" [lastChars]="8"></app-truncate> |                     <app-truncate [text]="vout.scriptpubkey_address" [lastChars]="8"></app-truncate> | ||||||
|                   </a> |                   </a> | ||||||
|  |                   <ng-template #pubkey_type> | ||||||
|  |                     <ng-container *ngIf="vout.scriptpubkey_type === 'p2pk'; else scriptpubkey_type"> | ||||||
|  |                       P2PK <a class="address p2pk-address" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey.slice(2, -2)]" title="{{ vout.scriptpubkey.slice(2, -2) }}"> | ||||||
|  |                         <app-truncate [text]="vout.scriptpubkey.slice(2, -2)" [lastChars]="8"></app-truncate> | ||||||
|  |                       </a> | ||||||
|  |                     </ng-container> | ||||||
|  |                   </ng-template> | ||||||
|                   <div> |                   <div> | ||||||
|                     <app-address-labels [vout]="vout" [channel]="tx._channels && tx._channels.outputs[vindex] ? tx._channels.outputs[vindex] : null"></app-address-labels> |                     <app-address-labels [vout]="vout" [channel]="tx._channels && tx._channels.outputs[vindex] ? tx._channels.outputs[vindex] : null"></app-address-labels> | ||||||
|                   </div> |                   </div> | ||||||
|  | |||||||
| @ -149,6 +149,15 @@ h2 { | |||||||
| 	font-family: monospace; | 	font-family: monospace; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .p2pk-address { | ||||||
|  | 	display: inline-block; | ||||||
|  | 	margin-left: 1em; | ||||||
|  | 	max-width: 100px; | ||||||
|  | 	@media (min-width: 576px) { | ||||||
|  | 		max-width: 200px | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .grey-info-text { | .grey-info-text { | ||||||
| 	color:#6c757d; | 	color:#6c757d; | ||||||
| 	font-style: italic; | 	font-style: italic; | ||||||
|  | |||||||
| @ -78,7 +78,7 @@ | |||||||
|           <a class="title-link" href="" [routerLink]="['/rbf' | relativeUrl]"> |           <a class="title-link" href="" [routerLink]="['/rbf' | relativeUrl]"> | ||||||
|             <h5 class="card-title d-inline" i18n="dashboard.latest-rbf-replacements">Latest replacements</h5> |             <h5 class="card-title d-inline" i18n="dashboard.latest-rbf-replacements">Latest replacements</h5> | ||||||
|             <span> </span> |             <span> </span> | ||||||
|             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> |             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: #4a68b9"></fa-icon> | ||||||
|           </a> |           </a> | ||||||
|           <table class="table lastest-replacements-table"> |           <table class="table lastest-replacements-table"> | ||||||
|             <thead> |             <thead> | ||||||
| @ -112,7 +112,7 @@ | |||||||
|             <a class="title-link" href="" [routerLink]="['/blocks' | relativeUrl]"> |             <a class="title-link" href="" [routerLink]="['/blocks' | relativeUrl]"> | ||||||
|               <h5 class="card-title d-inline" i18n="dashboard.latest-blocks">Latest blocks</h5> |               <h5 class="card-title d-inline" i18n="dashboard.latest-blocks">Latest blocks</h5> | ||||||
|               <span> </span> |               <span> </span> | ||||||
|               <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> |               <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: #4a68b9"></fa-icon> | ||||||
|             </a> |             </a> | ||||||
|             <table class="table lastest-blocks-table"> |             <table class="table lastest-blocks-table"> | ||||||
|               <thead> |               <thead> | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; | import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; | ||||||
| import { combineLatest, merge, Observable, of, Subscription } from 'rxjs'; | import { combineLatest, merge, Observable, of, Subscription } from 'rxjs'; | ||||||
| import { filter, map, scan, share, switchMap, tap } from 'rxjs/operators'; | import { catchError, filter, map, scan, share, switchMap, tap } from 'rxjs/operators'; | ||||||
| import { BlockExtended, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; | import { BlockExtended, OptimizedMempoolStats } from '../interfaces/node-api.interface'; | ||||||
| import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.interface'; | import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.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'; | ||||||
| @ -31,7 +31,7 @@ interface MempoolStatsData { | |||||||
|   styleUrls: ['./dashboard.component.scss'], |   styleUrls: ['./dashboard.component.scss'], | ||||||
|   changeDetection: ChangeDetectionStrategy.OnPush |   changeDetection: ChangeDetectionStrategy.OnPush | ||||||
| }) | }) | ||||||
| export class DashboardComponent implements OnInit, OnDestroy { | export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { | ||||||
|   featuredAssets$: Observable<any>; |   featuredAssets$: Observable<any>; | ||||||
|   network$: Observable<string>; |   network$: Observable<string>; | ||||||
|   mempoolBlocksData$: Observable<MempoolBlocksData>; |   mempoolBlocksData$: Observable<MempoolBlocksData>; | ||||||
| @ -57,6 +57,10 @@ export class DashboardComponent implements OnInit, OnDestroy { | |||||||
|     private seoService: SeoService |     private seoService: SeoService | ||||||
|   ) { } |   ) { } | ||||||
| 
 | 
 | ||||||
|  |   ngAfterViewInit(): void { | ||||||
|  |     this.stateService.focusSearchInputDesktop(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   ngOnDestroy(): void { |   ngOnDestroy(): void { | ||||||
|     this.currencySubscription.unsubscribe(); |     this.currencySubscription.unsubscribe(); | ||||||
|     this.websocketService.stopTrackRbfSummary(); |     this.websocketService.stopTrackRbfSummary(); | ||||||
| @ -155,7 +159,7 @@ export class DashboardComponent implements OnInit, OnDestroy { | |||||||
|             for (const block of blocks) { |             for (const block of blocks) { | ||||||
|               // @ts-ignore: Need to add an extra field for the template
 |               // @ts-ignore: Need to add an extra field for the template
 | ||||||
|               block.extras.pool.logo = `/resources/mining-pools/` + |               block.extras.pool.logo = `/resources/mining-pools/` + | ||||||
|                 block.extras.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; |                 block.extras.pool.slug + '.svg'; | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
|           return of(blocks.slice(0, 6)); |           return of(blocks.slice(0, 6)); | ||||||
| @ -167,7 +171,11 @@ export class DashboardComponent implements OnInit, OnDestroy { | |||||||
|     this.mempoolStats$ = this.stateService.connectionState$ |     this.mempoolStats$ = this.stateService.connectionState$ | ||||||
|       .pipe( |       .pipe( | ||||||
|         filter((state) => state === 2), |         filter((state) => state === 2), | ||||||
|         switchMap(() => this.apiService.list2HStatistics$()), |         switchMap(() => this.apiService.list2HStatistics$().pipe( | ||||||
|  |           catchError((e) => { | ||||||
|  |             return of(null); | ||||||
|  |           }) | ||||||
|  |         )), | ||||||
|         switchMap((mempoolStats) => { |         switchMap((mempoolStats) => { | ||||||
|           return merge( |           return merge( | ||||||
|             this.stateService.live2Chart$ |             this.stateService.live2Chart$ | ||||||
| @ -182,10 +190,14 @@ export class DashboardComponent implements OnInit, OnDestroy { | |||||||
|           ); |           ); | ||||||
|         }), |         }), | ||||||
|         map((mempoolStats) => { |         map((mempoolStats) => { | ||||||
|  |           if (mempoolStats) { | ||||||
|             return { |             return { | ||||||
|               mempool: mempoolStats, |               mempool: mempoolStats, | ||||||
|               weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])), |               weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])), | ||||||
|             }; |             }; | ||||||
|  |           } else { | ||||||
|  |             return null; | ||||||
|  |           } | ||||||
|         }), |         }), | ||||||
|         share(), |         share(), | ||||||
|       ); |       ); | ||||||
|  | |||||||
| @ -10,8 +10,8 @@ | |||||||
|       <div class="doc-content"> |       <div class="doc-content"> | ||||||
| 
 | 
 | ||||||
|         <div id="disclaimer"> |         <div id="disclaimer"> | ||||||
|           <table *ngIf="!mobileViewport"><tr><td><app-svg-images name="warning" class="disclaimer-warning" viewBox="0 0 304 304" fill="#ffc107" width="50" height="50"></app-svg-images></td><td><p i18n="faq.big-disclaimer"><b>mempool.space merely provides data about the Bitcoin network.</b> It cannot help you with retrieving funds, confirming your transaction quicker, etc.</p><p>For any such requests, you need to get in touch with the entity that helped make the transaction (wallet software, exchange company, etc).</p></td></tr></table> |           <table *ngIf="!mobileViewport"><tr><td><app-svg-images name="warning" class="disclaimer-warning" viewBox="0 0 304 304" fill="#ffc107" width="50" height="50"></app-svg-images></td><td><p i18n="faq.big-disclaimer"><b>mempool.space merely provides data about the Bitcoin network.</b> It cannot help you with retrieving funds, wallet issues, etc.</p><p>For any such requests, you need to get in touch with the entity that helped make the transaction (wallet software, exchange company, etc).</p></td></tr></table> | ||||||
|           <div *ngIf="mobileViewport"><app-svg-images name="warning" class="disclaimer-warning" viewBox="0 0 304 304" fill="#ffc107" width="50" height="50"></app-svg-images><p i18n="faq.big-disclaimer"><b>mempool.space merely provides data about the Bitcoin network.</b> It cannot help you with retrieving funds, confirming your transaction quicker, etc.</p><p>For any such requests, you need to get in touch with the entity that helped make the transaction (wallet software, exchange company, etc).</p></div> |           <div *ngIf="mobileViewport"><app-svg-images name="warning" class="disclaimer-warning" viewBox="0 0 304 304" fill="#ffc107" width="50" height="50"></app-svg-images><p i18n="faq.big-disclaimer"><b>mempool.space merely provides data about the Bitcoin network.</b> It cannot help you with retrieving funds, wallet issues, etc.</p><p>For any such requests, you need to get in touch with the entity that helped make the transaction (wallet software, exchange company, etc).</p></div> | ||||||
| 
 | 
 | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -129,6 +129,22 @@ export interface Address { | |||||||
|   address: string; |   address: string; | ||||||
|   chain_stats: ChainStats; |   chain_stats: ChainStats; | ||||||
|   mempool_stats: MempoolStats; |   mempool_stats: MempoolStats; | ||||||
|  |   is_pubkey?: boolean; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface ScriptHash { | ||||||
|  |   electrum?: boolean; | ||||||
|  |   scripthash: string; | ||||||
|  |   chain_stats: ChainStats; | ||||||
|  |   mempool_stats: MempoolStats; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface AddressOrScriptHash { | ||||||
|  |   electrum?: boolean; | ||||||
|  |   address?: string; | ||||||
|  |   scripthash?: string; | ||||||
|  |   chain_stats: ChainStats; | ||||||
|  |   mempool_stats: MempoolStats; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface ChainStats { | export interface ChainStats { | ||||||
|  | |||||||
| @ -110,6 +110,7 @@ export interface PoolInfo { | |||||||
|   regexes: string; // JSON array
 |   regexes: string; // JSON array
 | ||||||
|   addresses: string; // JSON array
 |   addresses: string; // JSON array
 | ||||||
|   emptyBlocks: number; |   emptyBlocks: number; | ||||||
|  |   slug: string; | ||||||
| } | } | ||||||
| export interface PoolStat { | export interface PoolStat { | ||||||
|   pool: PoolInfo; |   pool: PoolInfo; | ||||||
| @ -174,7 +175,7 @@ export interface TransactionStripped { | |||||||
|   vsize: number; |   vsize: number; | ||||||
|   value: number; |   value: number; | ||||||
|   rate?: number; // effective fee rate
 |   rate?: number; // effective fee rate
 | ||||||
|   status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf'; |   status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'rbf'; | ||||||
|   context?: 'projected' | 'actual'; |   context?: 'projected' | 'actual'; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -89,7 +89,7 @@ export interface TransactionStripped { | |||||||
|   vsize: number; |   vsize: number; | ||||||
|   value: number; |   value: number; | ||||||
|   rate?: number; // effective fee rate
 |   rate?: number; // effective fee rate
 | ||||||
|   status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf'; |   status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'rbf'; | ||||||
|   context?: 'projected' | 'actual'; |   context?: 'projected' | 'actual'; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,19 +1,43 @@ | |||||||
| <div class="box"> | <div class="box"> | ||||||
|   <table class="table table-borderless table-striped"> |   <div class="starting-balance" *ngIf="showStartingBalance"> | ||||||
|     <tbody> |     <h5 i18n="lightning.starting-balance|Channel starting balance">Starting balance</h5> | ||||||
|       <tr></tr> |     <div class="nodes"> | ||||||
|       <tr> |       <h5 class="alias">{{ left.alias }}</h5> | ||||||
|         <td i18n="lightning.starting-balance|Channel starting balance">Starting balance</td> |       <h5 class="alias">{{ right.alias }}</h5> | ||||||
|         <td *ngIf="showStartingBalance && minStartingBalance === maxStartingBalance"><app-sats [satoshis]="minStartingBalance"></app-sats></td> |     </div> | ||||||
|         <td *ngIf="showStartingBalance && minStartingBalance !== maxStartingBalance">{{ minStartingBalance | number : '1.0-0' }} - {{ maxStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></td> |     <div class="balances"> | ||||||
|         <td *ngIf="!showStartingBalance">?</td> |       <div class="balance left"> | ||||||
|       </tr> |         <span class="value" *ngIf="minStartingBalance !== maxStartingBalance">{{ minStartingBalance | number : '1.0-0' }} - {{ maxStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span> | ||||||
|       <tr *ngIf="channel.status === 2"> |         <span class="value" *ngIf="minStartingBalance === maxStartingBalance">{{ minStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span> | ||||||
|         <td i18n="lightning.closing-balance|Channel closing balance">Closing balance</td> |       </div> | ||||||
|         <td *ngIf="showClosingBalance && minClosingBalance === maxClosingBalance"><app-sats [satoshis]="minClosingBalance"></app-sats></td> |       <div class="balance right"> | ||||||
|         <td *ngIf="showClosingBalance && minClosingBalance !== maxClosingBalance">{{ minClosingBalance | number : '1.0-0' }} - {{ maxClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></td> |         <span class="value" *ngIf="minStartingBalance !== maxStartingBalance">{{ channel.capacity - maxStartingBalance | number : '1.0-0' }} - {{ channel.capacity - minStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span> | ||||||
|         <td *ngIf="!showClosingBalance">?</td> |         <span class="value" *ngIf="minStartingBalance === maxStartingBalance">{{ channel.capacity - maxStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span> | ||||||
|       </tr> |       </div> | ||||||
|     </tbody> |     </div> | ||||||
|   </table> |     <div class="balance-bar"> | ||||||
|  |       <div class="bar left" [class.hide-value]="hideStartingLeft" [style]="startingBalanceStyle.left"></div> | ||||||
|  |       <div class="bar center" [style]="startingBalanceStyle.center"></div> | ||||||
|  |       <div class="bar right"  [class.hide-value]="hideStartingRight" [style]="startingBalanceStyle.right"></div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |   <br> | ||||||
|  |   <div class="closing-balance" *ngIf="showClosingBalance"> | ||||||
|  |     <h5 i18n="lightning.closing-balance|Channel closing balance">Closing balance</h5> | ||||||
|  |     <div class="balances"> | ||||||
|  |       <div class="balance left"> | ||||||
|  |         <span class="value" *ngIf="minClosingBalance !== maxClosingBalance">{{ minClosingBalance | number : '1.0-0' }} - {{ maxClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span> | ||||||
|  |         <span class="value" *ngIf="minClosingBalance === maxClosingBalance">{{ minClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span> | ||||||
|  |       </div> | ||||||
|  |       <div class="balance right"> | ||||||
|  |         <span class="value" *ngIf="minClosingBalance !== maxClosingBalance">{{ channel.capacity - maxClosingBalance | number : '1.0-0' }} - {{ channel.capacity - minClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span> | ||||||
|  |         <span class="value" *ngIf="minClosingBalance === maxClosingBalance">{{ channel.capacity - maxClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div class="balance-bar"> | ||||||
|  |       <div class="bar left" [class.hide-value]="hideClosingLeft" [style]="closingBalanceStyle.left"></div> | ||||||
|  |       <div class="bar center" [style]="closingBalanceStyle.center"></div> | ||||||
|  |       <div class="bar right" [class.hide-value]="hideClosingRight" [style]="closingBalanceStyle.right"></div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
| </div> | </div> | ||||||
| @ -7,3 +7,97 @@ | |||||||
|     margin-bottom: 20px; |     margin-bottom: 20px; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .starting-balance, .closing-balance { | ||||||
|  |   width: 100%; | ||||||
|  | 
 | ||||||
|  |   h5 { | ||||||
|  |     text-align: center; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .nodes { | ||||||
|  |   display: none; | ||||||
|  |   flex-direction: row; | ||||||
|  |   align-items: baseline; | ||||||
|  |   justify-content: space-between; | ||||||
|  | 
 | ||||||
|  |   @media (max-width: 768px) { | ||||||
|  |     display: flex; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .balances { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: row; | ||||||
|  |   align-items: baseline; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   margin-bottom: 8px; | ||||||
|  | 
 | ||||||
|  |   .balance { | ||||||
|  |     &.left { | ||||||
|  |       text-align: start; | ||||||
|  |     } | ||||||
|  |     &.right { | ||||||
|  |       text-align: end; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .balance-bar { | ||||||
|  |   width: 100%; | ||||||
|  |   height: 2em; | ||||||
|  |   position: relative; | ||||||
|  | 
 | ||||||
|  |   .bar { | ||||||
|  |     position: absolute; | ||||||
|  |     top: 0; | ||||||
|  |     bottom: 0; | ||||||
|  |     height: 100%; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: row; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  | 
 | ||||||
|  |     &.left { | ||||||
|  |       background: #105fb0; | ||||||
|  |     } | ||||||
|  |     &.center { | ||||||
|  |       background: repeating-linear-gradient( | ||||||
|  |         60deg, | ||||||
|  |         #105fb0 0, | ||||||
|  |         #105fb0 12px, | ||||||
|  |         #1a9436 12px, | ||||||
|  |         #1a9436 24px | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     &.right { | ||||||
|  |       background: #1a9436; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .value { | ||||||
|  |       flex: 0; | ||||||
|  |       white-space: nowrap; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &.hide-value { | ||||||
|  |       .value { | ||||||
|  |         display: none; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @media (max-width: 768px) { | ||||||
|  |     height: 1em; | ||||||
|  | 
 | ||||||
|  |     .bar.center { | ||||||
|  |       background: repeating-linear-gradient( | ||||||
|  |         60deg, | ||||||
|  |         #105fb0 0, | ||||||
|  |         #105fb0 8px, | ||||||
|  |         #1a9436 8px, | ||||||
|  |         #1a9436 16px | ||||||
|  |       ) | ||||||
|  |     } | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @ -8,8 +8,8 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } f | |||||||
| }) | }) | ||||||
| export class ChannelCloseBoxComponent implements OnChanges { | export class ChannelCloseBoxComponent implements OnChanges { | ||||||
|   @Input() channel: any; |   @Input() channel: any; | ||||||
|   @Input() local: any; |   @Input() left: any; | ||||||
|   @Input() remote: any; |   @Input() right: any; | ||||||
| 
 | 
 | ||||||
|   showStartingBalance: boolean = false; |   showStartingBalance: boolean = false; | ||||||
|   showClosingBalance: boolean = false; |   showClosingBalance: boolean = false; | ||||||
| @ -18,29 +18,55 @@ export class ChannelCloseBoxComponent implements OnChanges { | |||||||
|   minClosingBalance: number; |   minClosingBalance: number; | ||||||
|   maxClosingBalance: number; |   maxClosingBalance: number; | ||||||
| 
 | 
 | ||||||
|  |   startingBalanceStyle: { | ||||||
|  |     left: string, | ||||||
|  |     center: string, | ||||||
|  |     right: string, | ||||||
|  |   } = { | ||||||
|  |     left: '', | ||||||
|  |     center: '', | ||||||
|  |     right: '', | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   closingBalanceStyle: { | ||||||
|  |     left: string, | ||||||
|  |     center: string, | ||||||
|  |     right: string, | ||||||
|  |   } = { | ||||||
|  |     left: '', | ||||||
|  |     center: '', | ||||||
|  |     right: '', | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   hideStartingLeft: boolean = false; | ||||||
|  |   hideStartingRight: boolean = false; | ||||||
|  |   hideClosingLeft: boolean = false; | ||||||
|  |   hideClosingRight: boolean = false; | ||||||
|  | 
 | ||||||
|   constructor() { } |   constructor() { } | ||||||
| 
 | 
 | ||||||
|   ngOnChanges(changes: SimpleChanges): void { |   ngOnChanges(changes: SimpleChanges): void { | ||||||
|     if (this.channel && this.local && this.remote) { |     let closingCapacity; | ||||||
|       this.showStartingBalance = (this.local.funding_balance || this.remote.funding_balance) && this.channel.funding_ratio; |     if (this.channel && this.left && this.right) { | ||||||
|       this.showClosingBalance = this.local.closing_balance || this.remote.closing_balance; |       this.showStartingBalance = (this.left.funding_balance || this.right.funding_balance) && this.channel.funding_ratio; | ||||||
|  |       this.showClosingBalance = this.left.closing_balance || this.right.closing_balance; | ||||||
| 
 | 
 | ||||||
|       if (this.channel.single_funded) { |       if (this.channel.single_funded) { | ||||||
|         if (this.local.funding_balance) { |         if (this.left.funding_balance) { | ||||||
|           this.minStartingBalance = this.channel.capacity; |           this.minStartingBalance = this.channel.capacity; | ||||||
|           this.maxStartingBalance = this.channel.capacity; |           this.maxStartingBalance = this.channel.capacity; | ||||||
|         } else if (this.remote.funding_balance) { |         } else if (this.right.funding_balance) { | ||||||
|           this.minStartingBalance = 0; |           this.minStartingBalance = 0; | ||||||
|           this.maxStartingBalance = 0; |           this.maxStartingBalance = 0; | ||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
|         this.minStartingBalance = clampRound(0, this.channel.capacity, this.local.funding_balance * this.channel.funding_ratio); |         this.minStartingBalance = clampRound(0, this.channel.capacity, this.left.funding_balance * this.channel.funding_ratio); | ||||||
|         this.maxStartingBalance = clampRound(0, this.channel.capacity, this.channel.capacity - (this.remote.funding_balance * this.channel.funding_ratio)); |         this.maxStartingBalance = clampRound(0, this.channel.capacity, this.channel.capacity - (this.right.funding_balance * this.channel.funding_ratio)); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       const closingCapacity = this.channel.capacity - this.channel.closing_fee; |       closingCapacity = this.channel.capacity - this.channel.closing_fee; | ||||||
|       this.minClosingBalance = clampRound(0, closingCapacity, this.local.closing_balance); |       this.minClosingBalance = clampRound(0, closingCapacity, this.left.closing_balance); | ||||||
|       this.maxClosingBalance = clampRound(0, closingCapacity, closingCapacity - this.remote.closing_balance); |       this.maxClosingBalance = clampRound(0, closingCapacity, closingCapacity - this.right.closing_balance); | ||||||
| 
 | 
 | ||||||
|       // margin of error to account for 2 x 330 sat anchor outputs
 |       // margin of error to account for 2 x 330 sat anchor outputs
 | ||||||
|       if (Math.abs(this.minClosingBalance - this.maxClosingBalance) <= 660) { |       if (Math.abs(this.minClosingBalance - this.maxClosingBalance) <= 660) { | ||||||
| @ -50,6 +76,26 @@ export class ChannelCloseBoxComponent implements OnChanges { | |||||||
|       this.showStartingBalance = false; |       this.showStartingBalance = false; | ||||||
|       this.showClosingBalance = false; |       this.showClosingBalance = false; | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     const startingMinPc = (this.minStartingBalance / this.channel.capacity) * 100; | ||||||
|  |     const startingMaxPc = (this.maxStartingBalance / this.channel.capacity) * 100; | ||||||
|  |     this.startingBalanceStyle = { | ||||||
|  |       left: `left: 0%; right: ${100 - startingMinPc}%;`, | ||||||
|  |       center: `left: ${startingMinPc}%; right: ${100 -startingMaxPc}%;`, | ||||||
|  |       right: `left: ${startingMaxPc}%; right: 0%;`, | ||||||
|  |     }; | ||||||
|  |     this.hideStartingLeft = startingMinPc < 15; | ||||||
|  |     this.hideStartingRight = startingMaxPc > 85; | ||||||
|  | 
 | ||||||
|  |     const closingMinPc = (this.minClosingBalance / closingCapacity) * 100; | ||||||
|  |     const closingMaxPc = (this.maxClosingBalance / closingCapacity) * 100; | ||||||
|  |     this.closingBalanceStyle = { | ||||||
|  |       left: `left: 0%; right: ${100 - closingMinPc}%;`, | ||||||
|  |       center: `left: ${closingMinPc}%; right: ${100 - closingMaxPc}%;`, | ||||||
|  |       right: `left: ${closingMaxPc}%; right: 0%;`, | ||||||
|  |     }; | ||||||
|  |     this.hideClosingLeft = closingMinPc < 15; | ||||||
|  |     this.hideClosingRight = closingMaxPc > 85; | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -75,14 +75,14 @@ | |||||||
|   <div class="row row-cols-1 row-cols-md-2" *ngIf="!error"> |   <div class="row row-cols-1 row-cols-md-2" *ngIf="!error"> | ||||||
|     <div class="col"> |     <div class="col"> | ||||||
|       <app-channel-box [channel]="channel.node_left"></app-channel-box> |       <app-channel-box [channel]="channel.node_left"></app-channel-box> | ||||||
|       <app-channel-close-box *ngIf="showCloseBoxes(channel)" [channel]="channel" [local]="channel.node_left" [remote]="channel.node_right"></app-channel-close-box> |  | ||||||
|     </div> |     </div> | ||||||
|     <div class="col"> |     <div class="col"> | ||||||
|       <app-channel-box [channel]="channel.node_right"></app-channel-box> |       <app-channel-box [channel]="channel.node_right"></app-channel-box> | ||||||
|       <app-channel-close-box *ngIf="showCloseBoxes(channel)" [channel]="channel" [local]="channel.node_right" [remote]="channel.node_left"></app-channel-close-box> |  | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| 
 | 
 | ||||||
|  |   <app-channel-close-box *ngIf="showCloseBoxes(channel)" [channel]="channel" [left]="channel.node_left" [right]="channel.node_right"></app-channel-close-box> | ||||||
|  | 
 | ||||||
|   <br> |   <br> | ||||||
| 
 | 
 | ||||||
|   <ng-container *ngIf="transactions$ | async as transactions"> |   <ng-container *ngIf="transactions$ | async as transactions"> | ||||||
|  | |||||||
| @ -61,7 +61,7 @@ | |||||||
|           <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/liquidity' | relativeUrl]"> |           <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/liquidity' | relativeUrl]"> | ||||||
|             <h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity Ranking</h5> |             <h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity Ranking</h5> | ||||||
|             <span> </span> |             <span> </span> | ||||||
|             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> |             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: #4a68b9"></fa-icon> | ||||||
|           </a> |           </a> | ||||||
|           <app-top-nodes-per-capacity [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-capacity> |           <app-top-nodes-per-capacity [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-capacity> | ||||||
|         </div> |         </div> | ||||||
| @ -75,7 +75,7 @@ | |||||||
|           <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/connectivity' | relativeUrl]"> |           <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/connectivity' | relativeUrl]"> | ||||||
|             <h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity Ranking</h5> |             <h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity Ranking</h5> | ||||||
|             <span> </span> |             <span> </span> | ||||||
|             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> |             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: #4a68b9"></fa-icon> | ||||||
|           </a> |           </a> | ||||||
|           <app-top-nodes-per-channels [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-channels> |           <app-top-nodes-per-channels [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-channels> | ||||||
|         </div> |         </div> | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | ||||||
| import { Observable } from 'rxjs'; | import { Observable } from 'rxjs'; | ||||||
| import { share } from 'rxjs/operators'; | import { share } from 'rxjs/operators'; | ||||||
| import { INodesRanking } from '../../interfaces/node-api.interface'; | import { INodesRanking } from '../../interfaces/node-api.interface'; | ||||||
| @ -12,7 +12,7 @@ import { LightningApiService } from '../lightning-api.service'; | |||||||
|   styleUrls: ['./lightning-dashboard.component.scss'], |   styleUrls: ['./lightning-dashboard.component.scss'], | ||||||
|   changeDetection: ChangeDetectionStrategy.OnPush, |   changeDetection: ChangeDetectionStrategy.OnPush, | ||||||
| }) | }) | ||||||
| export class LightningDashboardComponent implements OnInit { | export class LightningDashboardComponent implements OnInit, AfterViewInit { | ||||||
|   statistics$: Observable<any>; |   statistics$: Observable<any>; | ||||||
|   nodesRanking$: Observable<INodesRanking>; |   nodesRanking$: Observable<INodesRanking>; | ||||||
|   officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE; |   officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE; | ||||||
| @ -30,4 +30,7 @@ export class LightningDashboardComponent implements OnInit { | |||||||
|     this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share()); |     this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   ngAfterViewInit(): void { | ||||||
|  |     this.stateService.focusSearchInputDesktop(); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -14,7 +14,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -8,7 +8,7 @@ | |||||||
|             <h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity Ranking</h5> |             <h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity Ranking</h5> | ||||||
|             <span> </span> |             <span> </span> | ||||||
|             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" |             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" | ||||||
|               style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> |               style="vertical-align: text-top; font-size: 13px; color: #4a68b9"></fa-icon> | ||||||
|           </a> |           </a> | ||||||
|           <app-top-nodes-per-capacity [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-capacity> |           <app-top-nodes-per-capacity [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-capacity> | ||||||
|         </div> |         </div> | ||||||
| @ -22,7 +22,7 @@ | |||||||
|             <h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity Ranking</h5> |             <h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity Ranking</h5> | ||||||
|             <span> </span> |             <span> </span> | ||||||
|             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" |             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" | ||||||
|               style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> |               style="vertical-align: text-top; font-size: 13px; color: #4a68b9"></fa-icon> | ||||||
|           </a> |           </a> | ||||||
|           <app-top-nodes-per-channels [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-channels> |           <app-top-nodes-per-channels [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-channels> | ||||||
|         </div> |         </div> | ||||||
| @ -36,7 +36,7 @@ | |||||||
|             <h5 class="card-title d-inline" i18n="lightning.top-channels-age">Oldest nodes</h5> |             <h5 class="card-title d-inline" i18n="lightning.top-channels-age">Oldest nodes</h5> | ||||||
|             <span> </span> |             <span> </span> | ||||||
|             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" |             <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" | ||||||
|               style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> |               style="vertical-align: text-top; font-size: 13px; color: #4a68b9"></fa-icon> | ||||||
|           </a> |           </a> | ||||||
|           <app-oldest-nodes [widget]="true"></app-oldest-nodes> |           <app-oldest-nodes [widget]="true"></app-oldest-nodes> | ||||||
|         </div> |         </div> | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   padding: 0px 15px; |   padding: 0px 15px; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: calc(100vh - 250px); |   height: calc(100vh - 225px); | ||||||
|  |   min-height: 400px; | ||||||
|   @media (min-width: 992px) { |   @media (min-width: 992px) { | ||||||
|     height: calc(100vh - 150px); |     height: calc(100vh - 150px); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -1,9 +1,10 @@ | |||||||
| import { Injectable } from '@angular/core'; | import { Injectable } from '@angular/core'; | ||||||
| import { HttpClient, HttpParams } from '@angular/common/http'; | import { HttpClient, HttpParams } from '@angular/common/http'; | ||||||
| import { Observable } from 'rxjs'; | import { Observable, from, of, switchMap } from 'rxjs'; | ||||||
| import { Transaction, Address, Outspend, Recent, Asset } from '../interfaces/electrs.interface'; | import { Transaction, Address, Outspend, Recent, Asset, ScriptHash } from '../interfaces/electrs.interface'; | ||||||
| import { StateService } from './state.service'; | import { StateService } from './state.service'; | ||||||
| import { BlockExtended } from '../interfaces/node-api.interface'; | import { BlockExtended } from '../interfaces/node-api.interface'; | ||||||
|  | import { calcScriptHash$ } from '../bitcoin.utils'; | ||||||
| 
 | 
 | ||||||
| @Injectable({ | @Injectable({ | ||||||
|   providedIn: 'root' |   providedIn: 'root' | ||||||
| @ -65,6 +66,25 @@ export class ElectrsApiService { | |||||||
|     return this.httpClient.get<Address>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address); |     return this.httpClient.get<Address>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   getPubKeyAddress$(pubkey: string): Observable<Address> { | ||||||
|  |     const scriptpubkey = (pubkey.length === 130 ? '41' : '21') + pubkey + 'ac'; | ||||||
|  |     return this.getScriptHash$(scriptpubkey).pipe( | ||||||
|  |       switchMap((scripthash: ScriptHash) => { | ||||||
|  |         return of({ | ||||||
|  |           ...scripthash, | ||||||
|  |           address: pubkey, | ||||||
|  |           is_pubkey: true, | ||||||
|  |         }); | ||||||
|  |       }) | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   getScriptHash$(script: string): Observable<ScriptHash> { | ||||||
|  |     return from(calcScriptHash$(script)).pipe( | ||||||
|  |       switchMap(scriptHash => this.httpClient.get<ScriptHash>(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash)) | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getAddressTransactions$(address: string,  txid?: string): Observable<Transaction[]> { |   getAddressTransactions$(address: string,  txid?: string): Observable<Transaction[]> { | ||||||
|     let params = new HttpParams(); |     let params = new HttpParams(); | ||||||
|     if (txid) { |     if (txid) { | ||||||
| @ -73,6 +93,16 @@ export class ElectrsApiService { | |||||||
|     return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); |     return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   getScriptHashTransactions$(script: string,  txid?: string): Observable<Transaction[]> { | ||||||
|  |     let params = new HttpParams(); | ||||||
|  |     if (txid) { | ||||||
|  |       params = params.append('after_txid', txid); | ||||||
|  |     } | ||||||
|  |     return from(calcScriptHash$(script)).pipe( | ||||||
|  |       switchMap(scriptHash => this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash + '/txs', { params })), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getAsset$(assetId: string): Observable<Asset> { |   getAsset$(assetId: string): Observable<Asset> { | ||||||
|     return this.httpClient.get<Asset>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); |     return this.httpClient.get<Asset>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -96,7 +96,7 @@ export class MiningService { | |||||||
|         share: parseFloat((poolStat.blockCount / stats.blockCount * 100).toFixed(2)), |         share: parseFloat((poolStat.blockCount / stats.blockCount * 100).toFixed(2)), | ||||||
|         lastEstimatedHashrate: (poolStat.blockCount / stats.blockCount * stats.lastEstimatedHashrate / hashrateDivider).toFixed(2), |         lastEstimatedHashrate: (poolStat.blockCount / stats.blockCount * stats.lastEstimatedHashrate / hashrateDivider).toFixed(2), | ||||||
|         emptyBlockRatio: (poolStat.emptyBlocks / poolStat.blockCount * 100).toFixed(2), |         emptyBlockRatio: (poolStat.emptyBlocks / poolStat.blockCount * 100).toFixed(2), | ||||||
|         logo: `/resources/mining-pools/` + poolStat.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg', |         logo: `/resources/mining-pools/` + poolStat.slug + '.svg', | ||||||
|         ...poolStat |         ...poolStat | ||||||
|       }; |       }; | ||||||
|     }); |     }); | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ import { Router, NavigationStart } from '@angular/router'; | |||||||
| import { isPlatformBrowser } from '@angular/common'; | import { isPlatformBrowser } from '@angular/common'; | ||||||
| import { filter, map, scan, shareReplay } from 'rxjs/operators'; | import { filter, map, scan, shareReplay } from 'rxjs/operators'; | ||||||
| import { StorageService } from './storage.service'; | import { StorageService } from './storage.service'; | ||||||
|  | import { hasTouchScreen } from '../shared/pipes/bytes-pipe/utils'; | ||||||
| 
 | 
 | ||||||
| export interface MarkBlockState { | export interface MarkBlockState { | ||||||
|   blockHeight?: number; |   blockHeight?: number; | ||||||
| @ -113,6 +114,7 @@ export class StateService { | |||||||
|   mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition}>(); |   mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition}>(); | ||||||
|   blockTransactions$ = new Subject<Transaction>(); |   blockTransactions$ = new Subject<Transaction>(); | ||||||
|   isLoadingWebSocket$ = new ReplaySubject<boolean>(1); |   isLoadingWebSocket$ = new ReplaySubject<boolean>(1); | ||||||
|  |   isLoadingMempool$ = new BehaviorSubject<boolean>(true); | ||||||
|   vbytesPerSecond$ = new ReplaySubject<number>(1); |   vbytesPerSecond$ = new ReplaySubject<number>(1); | ||||||
|   previousRetarget$ = new ReplaySubject<number>(1); |   previousRetarget$ = new ReplaySubject<number>(1); | ||||||
|   backendInfo$ = new ReplaySubject<IBackendInfo>(1); |   backendInfo$ = new ReplaySubject<IBackendInfo>(1); | ||||||
| @ -138,6 +140,8 @@ export class StateService { | |||||||
|   fiatCurrency$: BehaviorSubject<string>; |   fiatCurrency$: BehaviorSubject<string>; | ||||||
|   rateUnits$: BehaviorSubject<string>; |   rateUnits$: BehaviorSubject<string>; | ||||||
| 
 | 
 | ||||||
|  |   searchFocus$: Subject<boolean> = new Subject<boolean>(); | ||||||
|  | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     @Inject(PLATFORM_ID) private platformId: any, |     @Inject(PLATFORM_ID) private platformId: any, | ||||||
|     @Inject(LOCALE_ID) private locale: string, |     @Inject(LOCALE_ID) private locale: string, | ||||||
| @ -355,4 +359,10 @@ export class StateService { | |||||||
|     this.blocks = this.blocks.slice(0, this.env.KEEP_BLOCKS_AMOUNT); |     this.blocks = this.blocks.slice(0, this.env.KEEP_BLOCKS_AMOUNT); | ||||||
|     this.blocksSubject$.next(this.blocks); |     this.blocksSubject$.next(this.blocks); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   focusSearchInputDesktop() { | ||||||
|  |     if (!hasTouchScreen()) { | ||||||
|  |       this.searchFocus$.next(true); | ||||||
|  |     }     | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -113,7 +113,7 @@ export class WebsocketService { | |||||||
|           this.stateService.connectionState$.next(2); |           this.stateService.connectionState$.next(2); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (this.stateService.connectionState$.value === 1) { |         if (this.stateService.connectionState$.value !== 2) { | ||||||
|           this.stateService.connectionState$.next(2); |           this.stateService.connectionState$.next(2); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
| @ -368,6 +368,11 @@ export class WebsocketService { | |||||||
| 
 | 
 | ||||||
|     if (response.loadingIndicators) { |     if (response.loadingIndicators) { | ||||||
|       this.stateService.loadingIndicators$.next(response.loadingIndicators); |       this.stateService.loadingIndicators$.next(response.loadingIndicators); | ||||||
|  |       if (response.loadingIndicators.mempool != null && response.loadingIndicators.mempool < 100) { | ||||||
|  |         this.stateService.isLoadingMempool$.next(true); | ||||||
|  |       } else { | ||||||
|  |         this.stateService.isLoadingMempool$.next(false); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (response.mempoolInfo) { |     if (response.mempoolInfo) { | ||||||
|  | |||||||
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