diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9f7f6caeb..e0aee68c5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,8 @@ updates: open-pull-requests-limit: 10 ignore: - dependency-name: "*" - update-types: ["version-update:semver-major"] + update-types: + ["version-update:semver-major", "version-update:semver-patch"] allow: - dependency-type: "production" @@ -18,7 +19,8 @@ updates: open-pull-requests-limit: 10 ignore: - dependency-name: "*" - update-types: ["version-update:semver-major"] + update-types: + ["version-update:semver-major", "version-update:semver-patch"] allow: - dependency-type: "production" @@ -28,7 +30,8 @@ updates: interval: weekly ignore: - dependency-name: "*" - update-types: ["version-update:semver-major"] + update-types: + ["version-update:semver-major", "version-update:semver-patch"] - package-ecosystem: docker directory: "/docker/frontend" @@ -36,7 +39,8 @@ updates: interval: weekly ignore: - dependency-name: "*" - update-types: ["version-update:semver-major"] + update-types: + ["version-update:semver-major", "version-update:semver-patch"] - package-ecosystem: "github-actions" directory: "/" @@ -44,4 +48,5 @@ updates: interval: weekly ignore: - dependency-name: "*" - update-types: ["version-update:semver-major"] + update-types: + ["version-update:semver-major", "version-update:semver-patch"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3c072689..6947a0f00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" strategy: matrix: - node: ["16", "17", "18"] + node: ["16", "17", "18", "20"] flavor: ["dev", "prod"] fail-fast: false runs-on: "ubuntu-latest" @@ -28,9 +28,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Install 1.70.x Rust toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: 1.70 + uses: dtolnay/rust-toolchain@1.70 - name: Install if: ${{ matrix.flavor == 'dev'}} @@ -60,7 +58,7 @@ jobs: if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" strategy: matrix: - node: ["16", "17", "18"] + node: ["16", "17", "18", "20"] flavor: ["dev", "prod"] fail-fast: false runs-on: "ubuntu-latest" @@ -99,3 +97,6 @@ jobs: - name: Build run: npm run build working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/LICENSE b/LICENSE index ac267d120..9f8592854 100644 --- a/LICENSE +++ b/LICENSE @@ -13,7 +13,7 @@ the terms of (at your option) either: proxy statement published on . 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 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, diff --git a/README.md b/README.md index b7a455cd5..dd2e62478 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# The Mempool Open Source Project™ [![mempool](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/ry4br7/master&style=flat-square)](https://dashboard.cypress.io/projects/ry4br7/runs) +# The Mempool Open Source Project® [![mempool](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/ry4br7/master&style=flat-square)](https://dashboard.cypress.io/projects/ry4br7/runs) https://user-images.githubusercontent.com/93150691/226236121-375ea64f-b4a1-4cc0-8fad-a6fb33226840.mp4 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 000000000..94143827e --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1 @@ +Dockerfile diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index e3df7d2fe..7948049fc 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -8,6 +8,7 @@ "API_URL_PREFIX": "/api/v1/", "POLL_RATE_MS": 2000, "CACHE_DIR": "./cache", + "CACHE_ENABLED": true, "CLEAR_PROTECTION_MINUTES": 20, "RECOMMENDED_FEE_PERCENTILE": 50, "BLOCK_WEIGHT_UNITS": 4000000, diff --git a/backend/package-lock.json b/backend/package-lock.json index 0a0b8b9d1..1a92552cb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "maxmind": "~4.3.11", "mysql2": "~3.5.2", "rust-gbt": "file:./rust-gbt", + "redis": "^4.6.6", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", "ws": "~8.13.0" @@ -1555,6 +1556,64 @@ "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": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", @@ -2718,6 +2777,14 @@ "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": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3678,6 +3745,14 @@ "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": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6577,6 +6652,19 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -8704,6 +8792,53 @@ "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": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", @@ -9604,6 +9739,11 @@ "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": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -10332,6 +10472,11 @@ "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": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12454,6 +12599,19 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 7ebc2e970..24da55e17 100644 --- a/backend/package.json +++ b/backend/package.json @@ -47,13 +47,14 @@ "maxmind": "~4.3.11", "mysql2": "~3.5.2", "rust-gbt": "file:./rust-gbt", + "redis": "^4.6.6", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", "ws": "~8.13.0" }, "devDependencies": { - "@babel/core": "^7.21.3", "@babel/code-frame": "^7.18.6", + "@babel/core": "^7.21.3", "@types/compression": "^1.7.2", "@types/crypto-js": "^4.1.1", "@types/express": "^4.17.17", diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index 4213f0ffb..ab700c466 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -10,6 +10,7 @@ "AUTOMATIC_BLOCK_REINDEXING": false, "POLL_RATE_MS": 3, "CACHE_DIR": "__MEMPOOL_CACHE_DIR__", + "CACHE_ENABLED": true, "CLEAR_PROTECTION_MINUTES": 4, "RECOMMENDED_FEE_PERCENTILE": 5, "BLOCK_WEIGHT_UNITS": 6, @@ -127,5 +128,9 @@ "AUDIT": false, "AUDIT_START_HEIGHT": 774000, "SERVERS": [] + }, + "REDIS": { + "ENABLED": false, + "UNIX_SOCKET_PATH": "/tmp/redis.sock" } } diff --git a/backend/src/__tests__/api/difficulty-adjustment.test.ts b/backend/src/__tests__/api/difficulty-adjustment.test.ts index da1ac0d2c..c3e8e1a88 100644 --- a/backend/src/__tests__/api/difficulty-adjustment.test.ts +++ b/backend/src/__tests__/api/difficulty-adjustment.test.ts @@ -1,4 +1,8 @@ -import { calcDifficultyAdjustment, DifficultyAdjustment } from '../../api/difficulty-adjustment'; +import { + calcBitsDifference, + calcDifficultyAdjustment, + DifficultyAdjustment, +} from '../../api/difficulty-adjustment'; describe('Mempool Difficulty Adjustment', () => { test('should calculate Difficulty Adjustments properly', () => { @@ -86,4 +90,46 @@ describe('Mempool Difficulty Adjustment', () => { 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/ + ); + }); }); diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index dc1beaa46..edfcc7f47 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -23,6 +23,7 @@ describe('Mempool Backend Config', () => { AUTOMATIC_BLOCK_REINDEXING: false, POLL_RATE_MS: 2000, CACHE_DIR: './cache', + CACHE_ENABLED: true, CLEAR_PROTECTION_MINUTES: 20, RECOMMENDED_FEE_PERCENTILE: 50, BLOCK_WEIGHT_UNITS: 4000000, @@ -127,6 +128,11 @@ describe('Mempool Backend Config', () => { AUDIT_START_HEIGHT: 774000, 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.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 if (root === 'MEMPOOL' && key === 'HTTP_PORT') { console.log('skipping check for MEMPOOL_HTTP_PORT'); - return; + continue; } switch (typeof value) { case 'object': { if (Array.isArray(value)) { - return; + continue; } else { parseJson(value, key); } diff --git a/backend/src/api/audit.ts b/backend/src/api/audit.ts index f7aecfca8..a909fc2b6 100644 --- a/backend/src/api/audit.ts +++ b/backend/src/api/audit.ts @@ -15,7 +15,7 @@ class Audit { const matches: string[] = []; // present in both mined block and template const added: string[] = []; // present in mined block, not in template 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 isDisplaced = {}; let displacedWeight = 0; @@ -36,8 +36,9 @@ class Audit { // look for transactions that were expected in the template, but missing from the mined block for (const txid of projectedBlocks[0].transactionIds) { if (!inBlock[txid]) { - if (rbfCache.isFullRbf(txid)) { - fullrbf.push(txid); + // allow missing transactions which either belong to a full rbf tree, or conflict with any transaction in the mined block + 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) { // tx is recent, may have reached the miner too late for inclusion fresh.push(txid); @@ -98,8 +99,8 @@ class Audit { if (inTemplate[tx.txid]) { matches.push(tx.txid); } else { - if (rbfCache.isFullRbf(tx.txid)) { - fullrbf.push(tx.txid); + if (rbfCache.has(tx.txid)) { + rbf.push(tx.txid); } else if (!isDisplaced[tx.txid]) { added.push(tx.txid); } @@ -147,7 +148,7 @@ class Audit { added, fresh, sigop: [], - fullrbf, + fullrbf: rbf, score, similarity, }; diff --git a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts index 7b2802d1b..7f4a5e53a 100644 --- a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts +++ b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -3,10 +3,12 @@ import { IEsploraApi } from './esplora-api.interface'; export interface AbstractBitcoinApi { $getRawMempool(): Promise; $getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise; + $getMempoolTransactions(lastTxid: string); $getTransactionHex(txId: string): Promise; $getBlockHeightTip(): Promise; $getBlockHashTip(): Promise; $getTxIdsForBlock(hash: string): Promise; + $getTxsForBlock(hash: string): Promise; $getBlockHash(height: number): Promise; $getBlockHeader(hash: string): Promise; $getBlock(hash: string): Promise; @@ -14,6 +16,8 @@ export interface AbstractBitcoinApi { $getAddress(address: string): Promise; $getAddressTransactions(address: string, lastSeenTxId: string): Promise; $getAddressPrefix(prefix: string): string[]; + $getScriptHash(scripthash: string): Promise; + $getScriptHashTransactions(address: string, lastSeenTxId: string): Promise; $sendRawTransaction(rawTransaction: string): Promise; $getOutspend(txId: string, vout: number): Promise; $getOutspends(txId: string): Promise; diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index cbcb2c571..132cda91a 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -5,6 +5,7 @@ import { IEsploraApi } from './esplora-api.interface'; import blocks from '../blocks'; import mempool from '../mempool'; import { TransactionExtended } from '../../mempool.interfaces'; +import transactionUtils from '../transaction-utils'; class BitcoinApi implements AbstractBitcoinApi { private rawMempoolCache: IBitcoinApi.RawMempool | null = null; @@ -59,9 +60,20 @@ class BitcoinApi implements AbstractBitcoinApi { }); } - $getTransactionHex(txId: string): Promise { - return this.$getRawTransaction(txId, true) - .then((tx) => tx.hex || ''); + $getMempoolTransactions(lastTxid: string): Promise { + return Promise.resolve([]); + } + + async $getTransactionHex(txId: string): Promise { + 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 { @@ -77,6 +89,10 @@ class BitcoinApi implements AbstractBitcoinApi { .then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx); } + $getTxsForBlock(hash: string): Promise { + throw new Error('Method getTxsForBlock not supported by the Bitcoin RPC API.'); + } + $getRawBlock(hash: string): Promise { return this.bitcoindClient.getBlock(hash, 0) .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.'); } + $getScriptHash(scripthash: string): Promise { + throw new Error('Method getScriptHash not supported by the Bitcoin RPC API.'); + } + + $getScriptHashTransactions(scripthash: string, lastSeenTxId: string): Promise { + throw new Error('Method getScriptHashTransactions not supported by the Bitcoin RPC API.'); + } + $getRawMempool(): Promise { return this.bitcoindClient.getRawMemPool(); } @@ -193,7 +217,7 @@ class BitcoinApi implements AbstractBitcoinApi { scriptpubkey: vout.scriptPubKey.hex, scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address : 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), }; }); @@ -203,7 +227,7 @@ class BitcoinApi implements AbstractBitcoinApi { is_coinbase: !!vin.coinbase, prevout: null, 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, txid: vin.txid || '', vout: vin.vout || 0, @@ -275,7 +299,7 @@ class BitcoinApi implements AbstractBitcoinApi { } const innerTx = await this.$getRawTransaction(vin.txid, false, false); vin.prevout = innerTx.vout[vin.vout]; - this.addInnerScriptsToVin(vin); + transactionUtils.addInnerScriptsToVin(vin); } return transaction; } @@ -314,7 +338,7 @@ class BitcoinApi implements AbstractBitcoinApi { } const innerTx = await this.$getRawTransaction(transaction.vin[i].txid, false, false); 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; } if (lazyPrevouts && transaction.vin.length > 12) { @@ -326,122 +350,6 @@ class BitcoinApi implements AbstractBitcoinApi { 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; diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index babc0aa53..f27bb7797 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -6,7 +6,7 @@ import websocketHandler from '../websocket-handler'; import mempool from '../mempool'; import feeApi from '../fee-api'; import mempoolBlocks from '../mempool-blocks'; -import bitcoinApi, { bitcoinCoreApi } from './bitcoin-api-factory'; +import bitcoinApi from './bitcoin-api-factory'; import { Common } from '../common'; import backendInfo from '../backend-info'; 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 + 'address/:address', this.getAddress) .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) ; } @@ -481,7 +483,7 @@ class BitcoinRoutes { returnBlocks.push(localBlock); nextHash = localBlock.previousblockhash; } else { - const block = await bitcoinCoreApi.$getBlock(nextHash); + const block = await bitcoinApi.$getBlock(nextHash); returnBlocks.push(block); 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 { + 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) { try { const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); diff --git a/backend/src/api/bitcoin/electrum-api.ts b/backend/src/api/bitcoin/electrum-api.ts index 9d1ef46d3..07c58dbc9 100644 --- a/backend/src/api/bitcoin/electrum-api.ts +++ b/backend/src/api/bitcoin/electrum-api.ts @@ -126,6 +126,77 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi { } } + async $getScriptHash(scripthash: string): Promise { + try { + const balance = await this.electrumClient.blockchainScripthash_getBalance(scripthash); + let history = memoryCache.get('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 { + try { + loadingIndicators.setProgress('address-' + scripthash, 0); + + const transactions: IEsploraApi.Transaction[] = []; + let history = memoryCache.get('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 { return this.electrumClient.blockchainScripthash_getBalance(this.encodeScriptHash(scriptHash)); } diff --git a/backend/src/api/bitcoin/esplora-api.interface.ts b/backend/src/api/bitcoin/esplora-api.interface.ts index 5b86952b0..55abe1d34 100644 --- a/backend/src/api/bitcoin/esplora-api.interface.ts +++ b/backend/src/api/bitcoin/esplora-api.interface.ts @@ -99,6 +99,13 @@ export namespace IEsploraApi { electrum?: boolean; } + export interface ScriptHash { + scripthash: string; + chain_stats: ChainStats; + mempool_stats: MempoolStats; + electrum?: boolean; + } + export interface ChainStats { funded_txo_count: number; funded_txo_sum: number; diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index ee7fa4765..ff10751e0 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -69,6 +69,10 @@ class ElectrsApi implements AbstractBitcoinApi { return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId); } + async $getMempoolTransactions(lastSeenTxid?: string): Promise { + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/mempool/txs' + (lastSeenTxid ? '/' + lastSeenTxid : '')); + } + $getTransactionHex(txId: string): Promise { return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex'); } @@ -85,6 +89,10 @@ class ElectrsApi implements AbstractBitcoinApi { return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids'); } + $getTxsForBlock(hash: string): Promise { + return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txs'); + } + $getBlockHash(height: number): Promise { return this.$queryWrapper(config.ESPLORA.REST_API_URL + '/block-height/' + height); } @@ -110,6 +118,14 @@ class ElectrsApi implements AbstractBitcoinApi { throw new Error('Method getAddressTransactions not implemented.'); } + $getScriptHash(scripthash: string): Promise { + throw new Error('Method getScriptHash not implemented.'); + } + + $getScriptHashTransactions(scripthash: string, txId?: string): Promise { + throw new Error('Method getScriptHashTransactions not implemented.'); + } + $getAddressPrefix(prefix: string): string[] { throw new Error('Method not implemented.'); } diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index fdf32f438..33196b052 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -26,12 +26,15 @@ import PricesRepository from '../repositories/PricesRepository'; import priceUpdater from '../tasks/price-updater'; import chainTips from './chain-tips'; import websocketHandler from './websocket-handler'; +import redisCache from './redis-cache'; +import rbfCache from './rbf-cache'; +import { calcBitsDifference } from './difficulty-adjustment'; class Blocks { private blocks: BlockExtended[] = []; private blockSummaries: BlockSummary[] = []; private currentBlockHeight = 0; - private currentDifficulty = 0; + private currentBits = 0; private lastDifficultyAdjustmentTime = 0; private previousDifficultyRetarget = 0; private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; @@ -70,6 +73,9 @@ class Blocks { * @param blockHash * @param blockHeight * @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 */ private async $getTransactionsExtended( @@ -80,62 +86,98 @@ class Blocks { quiet: boolean = false, addMempoolData: boolean = false, ): Promise { - const transactions: TransactionExtended[] = []; + const isEsplora = config.MEMPOOL.BACKEND === 'esplora'; + const transactionMap: { [txid: string]: TransactionExtended } = {}; + if (!txIds) { txIds = await bitcoinApi.$getTxIdsForBlock(blockHash); } const mempool = memPool.getMempool(); - let transactionsFound = 0; - let transactionsFetched = 0; + let foundInMempool = 0; + let totalFound = 0; - for (let i = 0; i < txIds.length; i++) { - if (mempool[txIds[i]]) { - // We update blocks before the mempool (index.ts), therefore we can - // optimize here by directly fetching txs in the "outdated" mempool - transactions.push(mempool[txIds[i]]); - transactionsFound++; - } else if (config.MEMPOOL.BACKEND === 'esplora' || !memPool.hasPriority() || i === 0) { - // 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}`); - } - try { - const tx = await transactionUtils.$getTransactionExtended(txIds[i], false, false, false, addMempoolData); - transactions.push(tx); - transactionsFetched++; - } 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 { - throw e; - } - } catch (e) { - if (i === 0) { - const msg = `Cannot fetch coinbase tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e); - logger.err(msg); - throw new Error(msg); - } else { - logger.err(`Cannot fetch tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e)); - } - } + // Copy existing transactions from the mempool + if (!onlyCoinbase) { + for (const txid of txIds) { + if (mempool[txid]) { + transactionMap[txid] = mempool[txid]; + foundInMempool++; + totalFound++; } } + } - if (onlyCoinbase === true) { - break; // Fetch the first transaction and exit + if (onlyCoinbase) { + try { + const coinbase = await transactionUtils.$getTransactionExtendedRetry(txIds[0], false, false, false, addMempoolData); + if (coinbase && coinbase.vin[0].is_coinbase) { + return [coinbase]; + } else { + const msg = `Expected a coinbase tx, but the backend API returned something else`; + logger.err(msg); + throw new Error(msg); + } + } catch (e) { + const msg = `Cannot fetch coinbase tx ${txIds[0]}. Reason: ` + (e instanceof Error ? e.message : e); + logger.err(msg); + throw new Error(msg); + } + } + + // Fetch remaining txs in bulk + 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) { - 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 { block.tx.forEach(tx => { - tx.fee = Object.values(tx.fee || {}).reduce((total, output) => total + output, 0); + if (!isFinite(Number(tx.fee))) { + tx.fee = Object.values(tx.fee || {}).reduce((total, output) => total + output, 0); + } }); return block; } @@ -376,8 +420,8 @@ class Blocks { let newlyIndexed = 0; let totalIndexed = indexedBlockSummariesHashesArray.length; let indexedThisRun = 0; - let timer = new Date().getTime() / 1000; - const startedAt = new Date().getTime() / 1000; + let timer = Date.now() / 1000; + const startedAt = Date.now() / 1000; for (const block of indexedBlocks) { if (indexedBlockSummariesHashes[block.hash] === true) { @@ -385,17 +429,24 @@ class Blocks { } // Logging - const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); + const elapsedSeconds = (Date.now() / 1000) - timer; if (elapsedSeconds > 5) { - const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); - const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds); + const runningFor = (Date.now() / 1000) - startedAt; + const blockPerSeconds = indexedThisRun / elapsedSeconds; 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); - timer = new Date().getTime() / 1000; + 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 = Date.now() / 1000; indexedThisRun = 0; } - await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary + + 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 + } // Logging indexedThisRun++; @@ -434,18 +485,18 @@ class Blocks { // Logging let count = 0; let countThisRun = 0; - let timer = new Date().getTime() / 1000; - const startedAt = new Date().getTime() / 1000; + let timer = Date.now() / 1000; + const startedAt = Date.now() / 1000; for (const height of unindexedBlockHeights) { // Logging 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) { - const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); - const blockPerSeconds = (countThisRun / elapsedSeconds); + const runningFor = (Date.now() / 1000) - startedAt; + const blockPerSeconds = countThisRun / elapsedSeconds; 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`); - timer = new Date().getTime() / 1000; + logger.debug(`Indexing cpfp clusters for #${height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${count}/${unindexedBlockHeights.length} (${progress}%) | elapsed: ${runningFor.toFixed(2)} seconds`); + timer = Date.now() / 1000; countThisRun = 0; } @@ -524,8 +575,8 @@ class Blocks { let totalIndexed = await blocksRepository.$blockCountBetweenHeight(currentBlockHeight, lastBlockToIndex); let indexedThisRun = 0; let newlyIndexed = 0; - const startedAt = new Date().getTime() / 1000; - let timer = new Date().getTime() / 1000; + const startedAt = Date.now() / 1000; + let timer = Date.now() / 1000; while (currentBlockHeight >= lastBlockToIndex) { const endBlock = Math.max(0, lastBlockToIndex, currentBlockHeight - chunkSize + 1); @@ -545,18 +596,18 @@ class Blocks { } ++indexedThisRun; ++totalIndexed; - const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer); + const elapsedSeconds = (Date.now() / 1000) - timer; if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) { - const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); - const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds); + const runningFor = (Date.now() / 1000) - startedAt; + const blockPerSeconds = indexedThisRun / elapsedSeconds; 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); - timer = new Date().getTime() / 1000; + 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 = Date.now() / 1000; indexedThisRun = 0; loadingIndicators.setProgress('block-indexing', progress, false); } 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 blockExtended = await this.$getBlockExtended(block, transactions); @@ -613,17 +664,17 @@ class Blocks { const heightDiff = blockHeightTip % 2016; const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff); 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.lastDifficultyAdjustmentTime = block.timestamp; - this.currentDifficulty = block.difficulty; + this.currentBits = block.bits; if (blockHeightTip >= 2016) { const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016); 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.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100; + this.previousDifficultyRetarget = calcBitsDifference(previousPeriodBlock.bits, block.bits); logger.debug(`Initial difficulty adjustment data set.`); } } else { @@ -647,14 +698,14 @@ class Blocks { const block = BitcoinApi.convertBlock(verboseBlock); const txIds: string[] = verboseBlock.tx.map(tx => tx.txid); 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 - for (let i = 0; i < transactions.length; i++) { - if (!transactions[i].fee && transactions[i].txid === verboseBlock.tx[i].txid) { - transactions[i].fee = verboseBlock.tx[i].fee * 100_000_000; - } + + // fill in missing transaction fee data from verboseBlock + for (let i = 0; i < transactions.length; i++) { + if (!transactions[i].fee && transactions[i].txid === verboseBlock.tx[i].txid) { + transactions[i].fee = (verboseBlock.tx[i].fee * 100_000_000) || 0; } } + const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions); const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions); const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, cpfpSummary.transactions); @@ -736,14 +787,18 @@ class Blocks { time: block.timestamp, height: block.height, 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.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100; + this.previousDifficultyRetarget = calcBitsDifference(this.currentBits, block.bits); this.lastDifficultyAdjustmentTime = block.timestamp; - this.currentDifficulty = block.difficulty; + this.currentBits = block.bits; } // wait for pending async callbacks to finish @@ -763,10 +818,18 @@ class Blocks { if (this.newBlockCallbacks.length) { 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(); } + // 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++; } @@ -811,7 +874,7 @@ class Blocks { } 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 blockExtended = await this.$getBlockExtended(block, transactions); @@ -823,7 +886,7 @@ class Blocks { } public async $indexStaleBlock(hash: string): Promise { - 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 blockExtended = await this.$getBlockExtended(block, transactions); @@ -848,7 +911,7 @@ class Blocks { } // 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) { return await this.$indexStaleBlock(hash); } else { @@ -877,13 +940,13 @@ class Blocks { let height = blockHeight; let summary: BlockSummary; - if (cpfpSummary) { + if (cpfpSummary && !Common.isLiquid()) { summary = { id: hash, transactions: cpfpSummary.transactions.map(tx => { return { txid: tx.txid, - fee: tx.fee, + fee: tx.fee || 0, vsize: tx.vsize, value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)), rate: tx.effectiveFeePerVsize @@ -891,10 +954,15 @@ class Blocks { }), }; } else { - // Call Core RPC - const block = await bitcoinClient.getBlock(hash, 2); - summary = this.summarizeBlock(block); - height = block.height; + if (config.MEMPOOL.BACKEND === 'esplora') { + const txs = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx)); + summary = this.summarizeBlockTransactions(hash, txs); + } else { + // Call Core RPC + const block = await bitcoinClient.getBlock(hash, 2); + summary = this.summarizeBlock(block); + height = block.height; + } } if (height == null) { const block = await bitcoinApi.$getBlock(hash); @@ -1017,8 +1085,17 @@ class Blocks { if (Common.blocksSummariesIndexingEnabled() && cleanBlock.fee_amt_percentiles === null) { cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash); if (cleanBlock.fee_amt_percentiles === null) { - const block = await bitcoinClient.getBlock(cleanBlock.hash, 2); - const summary = this.summarizeBlock(block); + + 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); + summary = this.summarizeBlock(block); + } + await BlocksSummariesRepository.$saveTransactions(cleanBlock.height, cleanBlock.hash, summary.transactions); cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash); } @@ -1078,19 +1155,29 @@ class Blocks { return this.currentBlockHeight; } - public async $indexCPFP(hash: string, height: number): Promise { - const block = await bitcoinClient.getBlock(hash, 2); - const transactions = block.tx.map(tx => { - tx.fee *= 100_000_000; - return tx; - }); + public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise { + 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); + transactions = block.tx.map(tx => { + tx.fee *= 100_000_000; + return tx; + }); + } + } - const summary = Common.calculateCpfp(height, transactions); + const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]); await this.$saveCpfp(hash, height, summary); const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions); await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats); + + return summary; } public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise { diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index cd9da3d2a..0e03fe32c 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -108,7 +108,7 @@ export class Common { static stripTransaction(tx: TransactionExtended): TransactionStripped { return { txid: tx.txid, - fee: tx.fee, + fee: tx.fee || 0, vsize: tx.weight / 4, value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0), rate: tx.effectiveFeePerVsize, diff --git a/backend/src/api/difficulty-adjustment.ts b/backend/src/api/difficulty-adjustment.ts index 1f37d8be9..23d0c33de 100644 --- a/backend/src/api/difficulty-adjustment.ts +++ b/backend/src/api/difficulty-adjustment.ts @@ -16,6 +16,68 @@ export interface DifficultyAdjustment { 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( DATime: number, nowSeconds: number, diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts index 1e428d8b6..6f603489a 100644 --- a/backend/src/api/disk-cache.ts +++ b/backend/src/api/disk-cache.ts @@ -29,7 +29,7 @@ class DiskCache { }; constructor() { - if (!cluster.isPrimary) { + if (!cluster.isPrimary || !config.MEMPOOL.CACHE_ENABLED) { return; } process.on('SIGINT', (e) => { @@ -39,7 +39,7 @@ class DiskCache { } async $saveCacheToDisk(sync: boolean = false): Promise { - if (!cluster.isPrimary) { + if (!cluster.isPrimary || !config.MEMPOOL.CACHE_ENABLED) { return; } if (this.isWritingCache) { @@ -175,10 +175,11 @@ class DiskCache { } async $loadMempoolCache(): Promise { - if (!fs.existsSync(DiskCache.FILE_NAME)) { + if (!config.MEMPOOL.CACHE_ENABLED || !fs.existsSync(DiskCache.FILE_NAME)) { return; } try { + const start = Date.now(); let data: any = {}; const cacheData = fs.readFileSync(DiskCache.FILE_NAME, 'utf8'); 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); if (!this.ignoreBlocksCache) { blocks.setBlocks(data.blocks); diff --git a/backend/src/api/explorer/channels.api.ts b/backend/src/api/explorer/channels.api.ts index ab29ed2c2..0b1b914fd 100644 --- a/backend/src/api/explorer/channels.api.ts +++ b/backend/src/api/explorer/channels.api.ts @@ -80,7 +80,7 @@ class ChannelsApi { public async $searchChannelsById(search: string): Promise { 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 [rows]: any = await DB.query(query, [searchStripped, searchStripped]); return rows; diff --git a/backend/src/api/lightning/clightning/clightning-convert.ts b/backend/src/api/lightning/clightning/clightning-convert.ts index 771dabcd7..55e4bd213 100644 --- a/backend/src/api/lightning/clightning/clightning-convert.ts +++ b/backend/src/api/lightning/clightning/clightning-convert.ts @@ -217,7 +217,7 @@ async function buildFullChannel(clChannelA: any, clChannelB: any): Promise { + 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() { this.mempoolInfo = await this.$getMempoolInfo(); } @@ -132,7 +182,7 @@ class Mempool { return txTimes; } - public async $updateMempool(transactions: string[]): Promise { + public async $updateMempool(transactions: string[], pollRate: number): Promise { logger.debug(`Updating mempool...`); // 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; this.updateTimerProgress(timer, 'got raw mempool'); const diff = transactions.length - currentMempoolSize; - const newTransactions: MempoolTransactionExtended[] = []; + let newTransactions: MempoolTransactionExtended[] = []; this.mempoolCacheDelta = Math.abs(diff); @@ -162,41 +212,66 @@ class Mempool { }; let intervalTimer = Date.now(); - for (const txid of transactions) { - if (!this.mempoolCache[txid]) { - try { - const transaction = await transactionUtils.$getMempoolTransactionExtended(txid, false, false, false); - this.updateTimerProgress(timer, 'fetched new transaction'); - this.mempoolCache[txid] = transaction; - if (this.inSync) { - this.txPerSecondArray.push(new Date().getTime()); - this.vBytesPerSecondArray.push({ - unixTime: new Date().getTime(), - vSize: transaction.vsize, - }); - } - hasChange = true; - newTransactions.push(transaction); - } catch (e: any) { - if (config.MEMPOOL.BACKEND === 'esplora' && e.response?.status === 404) { - this.missingTxCount++; - } - logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e)); - } - } - if (Date.now() - intervalTimer > 5_000) { - - if (this.inSync) { - // Break and restart mempool loop if we spend too much time processing - // new transactions that may lead to falling behind on block height - logger.debug('Breaking mempool loop because the 5s time limit exceeded.'); - break; - } else { - const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100; - logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`); - loadingIndicators.setProgress('mempool', progress); - 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) { + if (!this.mempoolCache[txid]) { + try { + const transaction = await transactionUtils.$getMempoolTransactionExtended(txid, false, false, false); + this.updateTimerProgress(timer, 'fetched new transaction'); + this.mempoolCache[txid] = transaction; + if (this.inSync) { + this.txPerSecondArray.push(new Date().getTime()); + this.vBytesPerSecondArray.push({ + unixTime: new Date().getTime(), + vSize: transaction.vsize, + }); + } + hasChange = true; + newTransactions.push(transaction); + + if (config.REDIS.ENABLED) { + await redisCache.$addTransaction(transaction); + } + } catch (e: any) { + if (config.MEMPOOL.BACKEND === 'esplora' && e.response?.status === 404) { + this.missingTxCount++; + } + logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e)); + } + } + + if (Date.now() - intervalTimer > Math.max(pollRate * 2, 5_000)) { + if (this.inSync) { + // Break and restart mempool loop if we spend too much time processing + // new transactions that may lead to falling behind on block height + logger.debug('Breaking mempool loop because the 5s time limit exceeded.'); + break; + } else { + const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100; + logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`); + if (Math.floor(progress) < 100) { + loadingIndicators.setProgress('mempool', progress); + } + intervalTimer = Date.now(); + } } } } @@ -219,7 +294,7 @@ class Mempool { logger.warn(`Mempool clear protection triggered because transactions.length: ${transactions.length} and currentMempoolSize: ${currentMempoolSize}.`); setTimeout(() => { 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); } @@ -246,12 +321,6 @@ class Mempool { const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx)); 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); if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { @@ -263,6 +332,19 @@ class Mempool { 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 time = end - start; logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`); diff --git a/backend/src/api/mining/mining.ts b/backend/src/api/mining/mining.ts index e190492b8..7376e7cf4 100644 --- a/backend/src/api/mining/mining.ts +++ b/backend/src/api/mining/mining.ts @@ -11,7 +11,7 @@ import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjust import config from '../../config'; import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository'; 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 database from '../../database'; @@ -201,7 +201,7 @@ class Mining { try { 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 indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps(); @@ -312,7 +312,7 @@ class Mining { const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp; 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 indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp); const lastMidnight = this.getDateMidnight(new Date()); @@ -421,8 +421,9 @@ class Mining { } 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 currentBits = genesisBlock.bits; let totalIndexed = 0; if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) { @@ -436,6 +437,7 @@ class Mining { const oldestConsecutiveBlock = await BlocksRepository.$getOldestConsecutiveBlock(); if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== -1) { + currentBits = oldestConsecutiveBlock.bits; currentDifficulty = oldestConsecutiveBlock.difficulty; } @@ -443,10 +445,11 @@ class Mining { let timer = new Date().getTime() / 1000; for (const block of blocks) { - if (block.difficulty !== currentDifficulty) { + if (block.bits !== currentBits) { if (indexedHeights[block.height] === true) { // Already indexed if (block.height >= oldestConsecutiveBlock.height) { currentDifficulty = block.difficulty; + currentBits = block.bits; } continue; } @@ -464,6 +467,7 @@ class Mining { totalIndexed++; if (block.height >= oldestConsecutiveBlock.height) { currentDifficulty = block.difficulty; + currentBits = block.bits; } } diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index 367ba1c0e..b5592252c 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -1,15 +1,17 @@ +import config from "../config"; import logger from "../logger"; import { MempoolTransactionExtended, TransactionStripped } from "../mempool.interfaces"; import bitcoinApi from './bitcoin/bitcoin-api-factory'; import { Common } from "./common"; +import redisCache from "./redis-cache"; -interface RbfTransaction extends TransactionStripped { +export interface RbfTransaction extends TransactionStripped { rbf?: boolean; mined?: boolean; fullRbf?: boolean; } -interface RbfTree { +export interface RbfTree { tx: RbfTransaction; time: number; interval?: number; @@ -28,6 +30,19 @@ export interface ReplacementInfo { newVsize: number; } +enum CacheOp { + Remove = 0, + Add = 1, + Change = 2, +} + +interface CacheEvent { + op: CacheOp; + type: 'tx' | 'tree' | 'exp'; + txid: string, + value?: any, +} + class RbfCache { private replacedBy: Map = new Map(); private replaces: Map = new Map(); @@ -36,11 +51,43 @@ class RbfCache { private treeMap: Map = new Map(); // map of txids to sequence ids private txs: Map = new Map(); private expiring: Map = new Map(); + private cacheQueue: CacheEvent[] = []; constructor() { 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 { if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) { return; @@ -49,7 +96,7 @@ class RbfCache { const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction; const newTime = newTxExtended.firstSeen || (Date.now() / 1000); newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe); - this.txs.set(newTx.txid, newTxExtended); + this.addTx(newTx.txid, newTxExtended); // maintain rbf trees let txFullRbf = false; @@ -66,7 +113,7 @@ class RbfCache { const treeId = this.treeMap.get(replacedTx.txid); if (treeId) { const tree = this.rbfTrees.get(treeId); - this.rbfTrees.delete(treeId); + this.removeTree(treeId); if (tree) { tree.interval = newTime - tree?.time; replacedTrees.push(tree); @@ -83,7 +130,7 @@ class RbfCache { replaces: [], }); treeFullRbf = treeFullRbf || !replacedTx.rbf; - this.txs.set(replacedTx.txid, replacedTxExtended); + this.addTx(replacedTx.txid, replacedTxExtended); } } newTx.fullRbf = txFullRbf; @@ -94,10 +141,27 @@ class RbfCache { fullRbf: treeFullRbf, replaces: replacedTrees }; - this.rbfTrees.set(treeId, newTree); + this.addTree(treeId, newTree); this.updateTreeMap(treeId, newTree); 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 { @@ -173,6 +237,7 @@ class RbfCache { this.setTreeMined(tree, txid); tree.mined = true; this.dirtyTrees.add(treeId); + this.cacheQueue.push({ op: CacheOp.Change, type: 'tree', txid: treeId }); } } this.evict(txid); @@ -181,7 +246,8 @@ class RbfCache { // flag a transaction as removed from the mempool public evict(txid: string, fast: boolean = false): void { 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(); for (const txid of this.expiring.keys()) { if ((this.expiring.get(txid) || 0) < now) { - this.expiring.delete(txid); + this.removeExpiration(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 @@ -216,14 +282,14 @@ class RbfCache { const replaces = this.replaces.get(txid); this.replaces.delete(txid); this.treeMap.delete(txid); - this.txs.delete(txid); - this.expiring.delete(txid); + this.removeTx(txid); + this.removeExpiration(txid); for (const tx of (replaces || [])) { // recursively remove prior versions from the cache this.replacedBy.delete(tx); // if this is the id of a tree, remove that too if (this.treeMap.get(tx) === tx) { - this.rbfTrees.delete(tx); + this.removeTree(tx); } this.remove(tx); } @@ -255,6 +321,33 @@ class RbfCache { } } + public async updateCache(): Promise { + 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 { 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 { txs.forEach(txEntry => { - this.txs.set(txEntry[0], txEntry[1]); + this.txs.set(txEntry.key, txEntry.value); }); for (const deflatedTree of trees) { await this.importTree(deflatedTree.root, deflatedTree.root, deflatedTree, this.txs); } expiring.forEach(expiringEntry => { - if (this.txs.has(expiringEntry[0])) { - this.expiring.set(expiringEntry[0], new Date(expiringEntry[1]).getTime()); + if (this.txs.has(expiringEntry.key)) { + this.expiring.set(expiringEntry.key, new Date(expiringEntry.value).getTime()); } }); this.cleanup(); @@ -360,8 +453,7 @@ class RbfCache { }; this.treeMap.set(txid, root); if (root === txid) { - this.rbfTrees.set(root, tree); - this.dirtyTrees.add(root); + this.addTree(root, tree); } return tree; } diff --git a/backend/src/api/redis-cache.ts b/backend/src/api/redis-cache.ts new file mode 100644 index 000000000..fcde8013a --- /dev/null +++ b/backend/src/api/redis-cache.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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('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 { + try { + await this.$ensureConnected(); + const rbfEntries = await this.scanKeys(`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(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 => { + 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(); diff --git a/backend/src/api/transaction-utils.ts b/backend/src/api/transaction-utils.ts index 8bebc42d8..e141a6076 100644 --- a/backend/src/api/transaction-utils.ts +++ b/backend/src/api/transaction-utils.ts @@ -3,6 +3,7 @@ import { IEsploraApi } from './bitcoin/esplora-api.interface'; import { Common } from './common'; import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; import * as bitcoinjs from 'bitcoinjs-lib'; +import logger from '../logger'; class TransactionUtils { 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 { + 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 addPrevouts @@ -31,10 +49,17 @@ class TransactionUtils { public async $getTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false, addMempoolData = false): Promise { let transaction: IEsploraApi.Transaction; if (forceCore === true) { - transaction = await bitcoinCoreApi.$getRawTransaction(txId, true); + transaction = await bitcoinCoreApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts); } else { 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) { return this.extendMempoolTransaction(transaction); } else { @@ -46,14 +71,13 @@ class TransactionUtils { 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 if (transaction.vsize) { // @ts-ignore return transaction; } - const feePerVbytes = Math.max(Common.isLiquid() ? 0.1 : 1, - (transaction.fee || 0) / (transaction.weight / 4)); + const feePerVbytes = (transaction.fee || 0) / (transaction.weight / 4); const transactionExtended: TransactionExtended = Object.assign({ vsize: Math.round(transaction.weight / 4), feePerVsize: feePerVbytes, @@ -68,13 +92,11 @@ class TransactionUtils { public extendMempoolTransaction(transaction: IEsploraApi.Transaction): MempoolTransactionExtended { const vsize = Math.ceil(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 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, - (transaction.fee || 0) / fractionalVsize); - const adjustedFeePerVsize = Math.max(Common.isLiquid() ? 0.1 : 1, - (transaction.fee || 0) / adjustedVsize); + const feePerVbytes = (transaction.fee || 0) / fractionalVsize; + const adjustedFeePerVsize = (transaction.fee || 0) / adjustedVsize; const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, { order: this.txidToOrdering(transaction.txid), vsize: Math.round(transaction.weight / 4), @@ -166,6 +188,122 @@ class TransactionUtils { 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(); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index a0c031175..0d0332523 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -183,15 +183,25 @@ class WebsocketHandler { } 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'])) { let matchedAddress = parsedMessage['track-address']; if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(parsedMessage['track-address'])) { matchedAddress = matchedAddress.toLowerCase(); } - client['track-address'] = matchedAddress; + 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-scriptpubkey'] = null; + } } else { 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']) { 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']; if (mBlockDeltas[index]) { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { @@ -644,7 +692,7 @@ class WebsocketHandler { memPool.handleMinedRbfTransactions(rbfTransactions); memPool.removeFromSpendMap(transactions); - if (config.MEMPOOL.AUDIT) { + if (config.MEMPOOL.AUDIT && memPool.isInSync()) { let projectedBlocks; let auditMempool = _memPool; // template calculation functions have mempool side effects, so calculate audits using @@ -665,7 +713,7 @@ class WebsocketHandler { 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 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']) { 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']; if (mBlockDeltas && mBlockDeltas[index]) { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { diff --git a/backend/src/config.ts b/backend/src/config.ts index 09d279537..3a028d0cd 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -12,6 +12,7 @@ interface IConfig { API_URL_PREFIX: string; POLL_RATE_MS: number; CACHE_DIR: string; + CACHE_ENABLED: boolean; CLEAR_PROTECTION_MINUTES: number; RECOMMENDED_FEE_PERCENTILE: number; BLOCK_WEIGHT_UNITS: number; @@ -137,7 +138,11 @@ interface IConfig { AUDIT: boolean; AUDIT_START_HEIGHT: number; SERVERS: string[]; - } + }, + REDIS: { + ENABLED: boolean; + UNIX_SOCKET_PATH: string; + }, } const defaults: IConfig = { @@ -150,6 +155,7 @@ const defaults: IConfig = { 'API_URL_PREFIX': '/api/v1/', 'POLL_RATE_MS': 2000, 'CACHE_DIR': './cache', + 'CACHE_ENABLED': true, 'CLEAR_PROTECTION_MINUTES': 20, 'RECOMMENDED_FEE_PERCENTILE': 50, 'BLOCK_WEIGHT_UNITS': 4000000, @@ -275,7 +281,11 @@ const defaults: IConfig = { 'AUDIT': false, 'AUDIT_START_HEIGHT': 774000, 'SERVERS': [], - } + }, + 'REDIS': { + 'ENABLED': false, + 'UNIX_SOCKET_PATH': '', + }, }; class Config implements IConfig { @@ -296,6 +306,7 @@ class Config implements IConfig { EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER']; MAXMIND: IConfig['MAXMIND']; REPLICATION: IConfig['REPLICATION']; + REDIS: IConfig['REDIS']; constructor() { const configs = this.merge(configFromFile, defaults); @@ -316,6 +327,7 @@ class Config implements IConfig { this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER; this.MAXMIND = configs.MAXMIND; this.REPLICATION = configs.REPLICATION; + this.REDIS = configs.REDIS; } merge = (...objects: object[]): IConfig => { diff --git a/backend/src/index.ts b/backend/src/index.ts index bbfaa9ff3..185a47067 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -41,6 +41,7 @@ import chainTips from './api/chain-tips'; import { AxiosError } from 'axios'; import v8 from 'v8'; import { formatBytes, getBytesUnit } from './utils/format'; +import redisCache from './api/redis-cache'; class Server { 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 syncAssets.syncAssets$(); if (config.MEMPOOL.ENABLED) { - await diskCache.$loadMempoolCache(); + if (config.MEMPOOL.CACHE_ENABLED) { + await diskCache.$loadMempoolCache(); + } else if (config.REDIS.ENABLED) { + await redisCache.$loadCache(); + } } if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) { @@ -183,14 +188,15 @@ class Server { } const newMempool = await bitcoinApi.$getRawMempool(); const numHandledBlocks = await blocks.$updateBlocks(); + const pollRate = config.MEMPOOL.POLL_RATE_MS * (indexer.indexerRunning ? 10 : 1); if (numHandledBlocks === 0) { - await memPool.$updateMempool(newMempool); + await memPool.$updateMempool(newMempool, pollRate); } indexer.$run(); // rerun immediately if we skipped the mempool update, otherwise wait POLL_RATE_MS 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); this.backendRetryCount = 0; } catch (e: any) { diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 078b85a03..0953f9b84 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -1,3 +1,4 @@ +import bitcoinApi from '../api/bitcoin/bitcoin-api-factory'; import { BlockExtended, BlockExtension, BlockPrice, EffectiveFeeStats } from '../mempool.interfaces'; import DB from '../database'; import logger from '../logger'; @@ -12,6 +13,7 @@ import config from '../config'; import chainTips from '../api/chain-tips'; import blocks from '../api/blocks'; import BlocksAuditsRepository from './BlocksAuditsRepository'; +import transactionUtils from '../api/transaction-utils'; interface DatabaseBlock { id: string; @@ -539,7 +541,7 @@ class BlocksRepository { */ public async $getBlocksDifficulty(): Promise { 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; } catch (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 { 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) { if (rows[i].height - rows[i + 1].height > 1) { return rows[i]; @@ -1036,8 +1038,17 @@ class BlocksRepository { { extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); if (extras.feePercentiles === null) { - const block = await bitcoinClient.getBlock(dbBlk.id, 2); - const summary = blocks.summarizeBlock(block); + + 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); + summary = blocks.summarizeBlock(block); + } + await BlocksSummariesRepository.$saveTransactions(dbBlk.height, dbBlk.id, summary.transactions); extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id); } diff --git a/contributors/Czino.txt b/contributors/Czino.txt new file mode 100644 index 000000000..08affb095 --- /dev/null +++ b/contributors/Czino.txt @@ -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 diff --git a/contributors/andrewtoth.txt b/contributors/andrewtoth.txt new file mode 100644 index 000000000..bd2ed3d06 --- /dev/null +++ b/contributors/andrewtoth.txt @@ -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 diff --git a/contributors/bguillaumat.txt b/contributors/bguillaumat.txt new file mode 100644 index 000000000..ac14a07c7 --- /dev/null +++ b/contributors/bguillaumat.txt @@ -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 diff --git a/contributors/devinbileck.txt b/contributors/devinbileck.txt new file mode 100644 index 000000000..bbabfad65 --- /dev/null +++ b/contributors/devinbileck.txt @@ -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 diff --git a/contributors/fiatjaf.txt b/contributors/fiatjaf.txt new file mode 100644 index 000000000..cdd716d3c --- /dev/null +++ b/contributors/fiatjaf.txt @@ -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 diff --git a/contributors/pedromvpg.txt b/contributors/pedromvpg.txt new file mode 100644 index 000000000..ce98c4167 --- /dev/null +++ b/contributors/pedromvpg.txt @@ -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 diff --git a/contributors/rishkwal.txt b/contributors/rishkwal.txt new file mode 100644 index 000000000..9a50bda6b --- /dev/null +++ b/contributors/rishkwal.txt @@ -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 diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 386e0d59d..bbe4df3d2 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -7,9 +7,10 @@ WORKDIR /build COPY . . 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 +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 ENV PATH="/root/.cargo/bin:$PATH" diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index 2ff76d5dd..8b47d53b8 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -8,6 +8,7 @@ "API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__", "POLL_RATE_MS": __MEMPOOL_POLL_RATE_MS__, "CACHE_DIR": "__MEMPOOL_CACHE_DIR__", + "CACHE_ENABLED": __MEMPOOL_CACHE_ENABLED__, "CLEAR_PROTECTION_MINUTES": __MEMPOOL_CLEAR_PROTECTION_MINUTES__, "RECOMMENDED_FEE_PERCENTILE": __MEMPOOL_RECOMMENDED_FEE_PERCENTILE__, "BLOCK_WEIGHT_UNITS": __MEMPOOL_BLOCK_WEIGHT_UNITS__, @@ -133,5 +134,9 @@ "AUDIT": __REPLICATION_AUDIT__, "AUDIT_START_HEIGHT": __REPLICATION_AUDIT_START_HEIGHT__, "SERVERS": __REPLICATION_SERVERS__ + }, + "REDIS": { + "ENABLED": __REDIS_ENABLED__, + "UNIX_SOCKET_PATH": "__REDIS_UNIX_SOCKET_PATH__" } } diff --git a/docker/backend/start.sh b/docker/backend/start.sh index c34d804b4..e05c73710 100755 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -9,6 +9,7 @@ __MEMPOOL_SPAWN_CLUSTER_PROCS__=${MEMPOOL_SPAWN_CLUSTER_PROCS:=0} __MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/} __MEMPOOL_POLL_RATE_MS__=${MEMPOOL_POLL_RATE_MS:=2000} __MEMPOOL_CACHE_DIR__=${MEMPOOL_CACHE_DIR:=./cache} +__MEMPOOL_CACHE_ENABLED__=${MEMPOOL_CACHE_ENABLED:=true} __MEMPOOL_CLEAR_PROTECTION_MINUTES__=${MEMPOOL_CLEAR_PROTECTION_MINUTES:=20} __MEMPOOL_RECOMMENDED_FEE_PERCENTILE__=${MEMPOOL_RECOMMENDED_FEE_PERCENTILE:=50} __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_SERVERS__=${REPLICATION_SERVERS:=[]} +# REDIS +__REDIS_ENABLED__=${REDIS_ENABLED:=true} +__REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=true} 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_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_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_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 @@ -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_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_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_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 @@ -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_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 diff --git a/docker/frontend/entrypoint.sh b/docker/frontend/entrypoint.sh index b6946578b..7d5ee313d 100644 --- a/docker/frontend/entrypoint.sh +++ b/docker/frontend/entrypoint.sh @@ -18,7 +18,7 @@ fi __TESTNET_ENABLED__=${TESTNET_ENABLED:=false} __SIGNET_ENABLED__=${SIGNET_ENABLED:=false} -__LIQUID_ENABLED__=${LIQUID_EANBLED:=false} +__LIQUID_ENABLED__=${LIQUID_ENABLED:=false} __LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false} __BISQ_ENABLED__=${BISQ_ENABLED:=false} __BISQ_SEPARATE_BACKEND__=${BISQ_SEPARATE_BACKEND:=false} diff --git a/frontend/.browserslistrc b/frontend/.browserslistrc index 80848532e..e6f1183e7 100644 --- a/frontend/.browserslistrc +++ b/frontend/.browserslistrc @@ -2,11 +2,15 @@ # For additional information regarding the format and rule options, please see: # 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: # npx browserslist -> 0.5% -last 2 versions +last 2 Chrome versions +last 1 Firefox version +last 2 Edge major versions +last 2 Safari major versions +last 2 iOS major versions Firefox ESR -not dead -not IE 9-11 # For IE 9-11 support, remove 'not'. \ No newline at end of file diff --git a/frontend/src/app/bitcoin.utils.ts b/frontend/src/app/bitcoin.utils.ts index 5419464a9..c4af730f6 100644 --- a/frontend/src/app/bitcoin.utils.ts +++ b/frontend/src/app/bitcoin.utils.ts @@ -281,3 +281,15 @@ export function isFeatureActive(network: string, height: number, feature: 'rbf' return false; } } + +export async function calcScriptHash$(script: string): Promise { + 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(''); +} \ No newline at end of file diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html index 381353948..23f50b544 100644 --- a/frontend/src/app/components/about/about.component.html +++ b/frontend/src/app/components/about/about.component.html @@ -411,7 +411,7 @@ Trademark Notice

- 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.

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 Trademark Policy and Guidelines for more details, published on <https://mempool.space/trademark-policy>. diff --git a/frontend/src/app/components/address/address-preview.component.ts b/frontend/src/app/components/address/address-preview.component.ts index 713f09f14..844def9fd 100644 --- a/frontend/src/app/components/address/address-preview.component.ts +++ b/frontend/src/app/components/address/address-preview.component.ts @@ -64,13 +64,15 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { this.address = null; this.addressInfo = null; 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.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); - return this.electrsApiService.getAddress$(this.addressString) - .pipe( + return (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) => { this.isLoadingAddress = false; this.error = err; diff --git a/frontend/src/app/components/address/address.component.scss b/frontend/src/app/components/address/address.component.scss index 37abcc49e..fe0729b94 100644 --- a/frontend/src/app/components/address/address.component.scss +++ b/frontend/src/app/components/address/address.component.scss @@ -81,6 +81,7 @@ h1 { top: 11px; } @media (min-width: 768px) { + max-width: calc(100% - 180px); top: 17px; } } diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index 57439f983..e9bfaaa1b 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; 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 { StateService } from '../../services/state.service'; import { AudioService } from '../../services/audio.service'; @@ -72,7 +72,7 @@ export class AddressComponent implements OnInit, OnDestroy { this.addressInfo = null; document.body.scrollTo(0, 0); 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.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( - switchMap(() => this.electrsApiService.getAddress$(this.addressString) - .pipe( + switchMap(() => ( + 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) => { this.isLoadingAddress = false; this.error = err; @@ -114,7 +117,9 @@ export class AddressComponent implements OnInit, OnDestroy { this.updateChainStats(); this.isLoadingAddress = false; 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) => { this.tempTransactions = transactions; @@ -161,31 +166,8 @@ export class AddressComponent implements OnInit, OnDestroy { }); this.stateService.mempoolTransactions$ - .subscribe((transaction) => { - if (this.transactions.some((t) => t.txid === transaction.txid)) { - 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; - } - }); + .subscribe(tx => { + this.addTransaction(tx); }); this.stateService.blockTransactions$ @@ -195,12 +177,47 @@ export class AddressComponent implements OnInit, OnDestroy { tx.status = transaction.status; this.transactions = this.transactions.slice(); this.audioService.playSound('magic'); + } else { + if (this.addTransaction(transaction, false)) { + this.audioService.playSound('magic'); + } } this.totalConfirmedTxCount++; 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() { if (this.isLoadingTransactions || !this.totalConfirmedTxCount || this.loadedConfirmedTxCount >= this.totalConfirmedTxCount) { return; diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss index 47c87a45c..f4f4dcc77 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.scss @@ -25,7 +25,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss index fae81952b..b73d55685 100644 --- a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss @@ -25,7 +25,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/components/block-health-graph/block-health-graph.component.scss b/frontend/src/app/components/block-health-graph/block-health-graph.component.scss index f8403bad5..7b8154bae 100644 --- a/frontend/src/app/components/block-health-graph/block-health-graph.component.scss +++ b/frontend/src/app/components/block-health-graph/block-health-graph.component.scss @@ -25,7 +25,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index 452bb38f5..1b8c88704 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -38,7 +38,7 @@ export default class TxView implements TransactionStripped { value: number; feerate: 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'; scene?: BlockScene; @@ -207,7 +207,7 @@ export default class TxView implements TransactionStripped { return auditColors.censored; case 'missing': case 'sigop': - case 'fullrbf': + case 'rbf': return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; case 'fresh': case 'freshcpfp': diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html index 59450326b..c62779b69 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html @@ -53,7 +53,7 @@ Recently CPFP'd Added Marginal fee rate - Full RBF + Conflicting diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss index f8403bad5..7b8154bae 100644 --- a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss @@ -25,7 +25,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss index f8403bad5..7b8154bae 100644 --- a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss +++ b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.scss @@ -25,7 +25,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 4be6e3aff..ec9a49504 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -144,10 +144,12 @@ export class BlockComponent implements OnInit, OnDestroy { for (const block of blocks) { if (block.id === this.blockHash) { this.block = block; - block.extras.minFee = this.getMinBlockFee(block); - block.extras.maxFee = this.getMaxBlockFee(block); - if (block?.extras?.reward != undefined) { - this.fees = block.extras.reward / 100000000 - this.blockSubsidy; + if (block.extras) { + block.extras.minFee = this.getMinBlockFee(block); + block.extras.maxFee = this.getMaxBlockFee(block); + if (block?.extras?.reward != undefined) { + this.fees = block.extras.reward / 100000000 - this.blockSubsidy; + } } } else if (block.height === this.block?.height) { this.block.stale = true; @@ -246,8 +248,10 @@ export class BlockComponent implements OnInit, OnDestroy { } this.updateAuditAvailableFromBlockHeight(block.height); this.block = block; - block.extras.minFee = this.getMinBlockFee(block); - block.extras.maxFee = this.getMaxBlockFee(block); + if (block.extras) { + block.extras.minFee = this.getMinBlockFee(block); + block.extras.maxFee = this.getMaxBlockFee(block); + } this.blockHeight = block.height; this.lastBlockHeight = this.blockHeight; this.nextBlockHeight = block.height + 1; @@ -335,7 +339,7 @@ export class BlockComponent implements OnInit, OnDestroy { const isSelected = {}; const isFresh = {}; const isSigop = {}; - const isFullRbf = {}; + const isRbf = {}; this.numMissing = 0; this.numUnexpected = 0; @@ -359,7 +363,7 @@ export class BlockComponent implements OnInit, OnDestroy { isSigop[txid] = true; } for (const txid of blockAudit.fullrbfTxs || []) { - isFullRbf[txid] = true; + isRbf[txid] = true; } // set transaction statuses for (const tx of blockAudit.template) { @@ -377,8 +381,8 @@ export class BlockComponent implements OnInit, OnDestroy { } } else if (isSigop[tx.txid]) { tx.status = 'sigop'; - } else if (isFullRbf[tx.txid]) { - tx.status = 'fullrbf'; + } else if (isRbf[tx.txid]) { + tx.status = 'rbf'; } else { tx.status = 'missing'; } @@ -394,8 +398,8 @@ export class BlockComponent implements OnInit, OnDestroy { tx.status = 'added'; } else if (inTemplate[tx.txid]) { tx.status = 'found'; - } else if (isFullRbf[tx.txid]) { - tx.status = 'fullrbf'; + } else if (isRbf[tx.txid]) { + tx.status = 'rbf'; } else { tx.status = 'selected'; isSelected[tx.txid] = true; diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts index 245973885..ba066d10a 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -113,8 +113,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { const animate = this.chainTip != null && latestHeight > this.chainTip; for (const block of blocks) { - block.extras.minFee = this.getMinBlockFee(block); - block.extras.maxFee = this.getMaxBlockFee(block); + if (block?.extras) { + block.extras.minFee = this.getMinBlockFee(block); + block.extras.maxFee = this.getMaxBlockFee(block); + } } this.blocks = blocks; @@ -251,7 +253,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { if (height >= 0) { this.cacheService.loadBlock(height); block = this.cacheService.getCachedBlock(height) || null; - if (block) { + if (block?.extras) { block.extras.minFee = this.getMinBlockFee(block); block.extras.maxFee = this.getMaxBlockFee(block); } @@ -293,8 +295,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { onBlockLoaded(block: BlockExtended) { const blockIndex = this.height - block.height; if (blockIndex >= 0 && blockIndex < this.blocks.length) { - block.extras.minFee = this.getMinBlockFee(block); - block.extras.maxFee = this.getMaxBlockFee(block); + if (block?.extras) { + block.extras.minFee = this.getMinBlockFee(block); + block.extras.maxFee = this.getMaxBlockFee(block); + } this.blocks[blockIndex] = block; this.blockStyles[blockIndex] = this.getStyleForBlock(block, blockIndex); } diff --git a/frontend/src/app/components/blockchain/blockchain.component.ts b/frontend/src/app/components/blockchain/blockchain.component.ts index 5eb2ed481..7619587d8 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.ts +++ b/frontend/src/app/components/blockchain/blockchain.component.ts @@ -82,9 +82,7 @@ export class BlockchainComponent implements OnInit, OnDestroy { } this.mempoolOffset = Math.max(0, width - this.dividerOffset); this.cd.markForCheck(); - setTimeout(() => { - this.mempoolOffsetChange.emit(this.mempoolOffset); - }, 0); + this.mempoolOffsetChange.emit(this.mempoolOffset); } @HostListener('window:resize', ['$event']) diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.ts b/frontend/src/app/components/blocks-list/blocks-list.component.ts index 2b54058e8..cec925270 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.ts +++ b/frontend/src/app/components/blocks-list/blocks-list.component.ts @@ -68,7 +68,7 @@ export class BlocksList implements OnInit { for (const block of blocks) { // @ts-ignore: Need to add an extra field for the template block.extras.pool.logo = `/resources/mining-pools/` + - block.extras.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; + block.extras.pool.slug + '.svg'; } } if (this.widget) { @@ -84,10 +84,10 @@ export class BlocksList implements OnInit { .pipe( switchMap((blocks) => { 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; - return blocks; + return of(blocks); }) ) ]) @@ -102,7 +102,7 @@ export class BlocksList implements OnInit { if (this.stateService.env.MINING_DASHBOARD) { // @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.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'; + blocks[1][0].extras.pool.slug + '.svg'; } acc.unshift(blocks[1][0]); acc = acc.slice(0, this.widget ? 6 : 15); diff --git a/frontend/src/app/components/difficulty/difficulty.component.ts b/frontend/src/app/components/difficulty/difficulty.component.ts index d3983c939..a2c03dc56 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.ts +++ b/frontend/src/app/components/difficulty/difficulty.component.ts @@ -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 { map, switchMap } from 'rxjs/operators'; import { StateService } from '../..//services/state.service'; @@ -61,6 +61,7 @@ export class DifficultyComponent implements OnInit { constructor( public stateService: StateService, + private cd: ChangeDetectorRef, @Inject(LOCALE_ID) private locale: string, ) { } @@ -189,9 +190,15 @@ export class DifficultyComponent implements OnInit { return shapes; } + @HostListener('pointerdown', ['$event']) + onPointerDown(event) { + this.onPointerMove(event); + } + @HostListener('pointermove', ['$event']) onPointerMove(event) { this.tooltipPosition = { x: event.clientX, y: event.clientY }; + this.cd.markForCheck(); } onHover(event, rect): void { diff --git a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts index f275588a1..212510e71 100644 --- a/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts +++ b/frontend/src/app/components/fee-distribution-graph/fee-distribution-graph.component.ts @@ -74,14 +74,14 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges, OnDestr this.labelInterval = this.numSamples / this.numLabels; while (nextSample <= maxBlockVSize) { if (txIndex >= txs.length) { - samples.push([(1 - (sampleIndex / this.numSamples)) * 100, 0]); + samples.push([(1 - (sampleIndex / this.numSamples)) * 100, 0.000001]); nextSample += sampleInterval; sampleIndex++; continue; } 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; sampleIndex++; } @@ -118,7 +118,9 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges, OnDestr }, }, 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', // nameLocation: 'middle', splitLine: { @@ -129,12 +131,16 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges, OnDestr } }, axisLabel: { + show: true, formatter: (value: number): string => { const unitValue = this.weightMode ? value / 4 : value; const selectedPowerOfTen = selectPowerOfTen(unitValue); const newVal = Math.round(unitValue / selectedPowerOfTen.divider); return `${newVal}${selectedPowerOfTen.unit}`; }, + }, + axisTick: { + show: true, } }, series: [{ diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss index 0caa35f33..886608573 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss @@ -25,7 +25,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.scss b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.scss index 3b1083505..64a4dcb3d 100644 --- a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.scss +++ b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.scss @@ -25,7 +25,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/components/master-page/master-page.component.scss b/frontend/src/app/components/master-page/master-page.component.scss index fc8b4eb5a..95b9474b9 100644 --- a/frontend/src/app/components/master-page/master-page.component.scss +++ b/frontend/src/app/components/master-page/master-page.component.scss @@ -64,7 +64,9 @@ li.nav-item { .navbar-collapse { - flex-basis: auto; + @media (min-width: 564px) { + flex-basis: auto; + } justify-content: flex-end; } diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index 3ec240b78..cedcf03f4 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -31,6 +31,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { @Input() getHref?: (index) => string = (index) => `/mempool-block/${index}`; @Input() allBlocks: boolean = false; + mempoolWidth: number = 0; @Output() widthChange: EventEmitter = new EventEmitter(); specialBlocks = specialBlocks; @@ -49,6 +50,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { blockSubscription: Subscription; networkSubscription: Subscription; chainTipSubscription: Subscription; + keySubscription: Subscription; + isTabHiddenSubscription: Subscription; network = ''; now = new Date().getTime(); timeOffset = 0; @@ -115,8 +118,15 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.calculateTransactionPosition(); }); this.reduceMempoolBlocksToFitScreen(this.mempoolBlocks); - this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); - this.loadingBlocks$ = this.stateService.isLoadingWebSocket$; + this.isTabHiddenSubscription = this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); + this.loadingBlocks$ = combineLatest([ + this.stateService.isLoadingWebSocket$, + this.stateService.isLoadingMempool$ + ]).pipe( + switchMap(([loadingBlocks, loadingMempool]) => { + return of(loadingBlocks || loadingMempool); + }) + ); this.mempoolBlocks$ = merge( of(true), @@ -155,7 +165,11 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { }), tap(() => { 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$ .subscribe((network) => this.network = network); - this.stateService.keyNavigation$.subscribe((event) => { + this.keySubscription = this.stateService.keyNavigation$.subscribe((event) => { if (this.markIndex === undefined) { return; } @@ -223,13 +237,12 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { if (this.mempoolBlocks[this.markIndex - 1]) { this.router.navigate([this.relativeUrlPipe.transform('mempool-block/'), this.markIndex - 1]); } else { - this.stateService.blocks$ - .pipe(map((blocks) => blocks[0])) - .subscribe((block) => { - if (this.stateService.latestBlockHeight === block.height) { - this.router.navigate([this.relativeUrlPipe.transform('/block/'), block.id], { state: { data: { block } }}); - } - }); + const blocks = this.stateService.blocksSubject$.getValue(); + for (const block of (blocks || [])) { + if (this.stateService.latestBlockHeight === block.height) { + this.router.navigate([this.relativeUrlPipe.transform('/block/'), block.id], { state: { data: { block } }}); + } + } } } else if (event.key === nextKey) { if (this.mempoolBlocks[this.markIndex + 1]) { @@ -253,6 +266,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.networkSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe(); this.chainTipSubscription.unsubscribe(); + this.keySubscription.unsubscribe(); + this.isTabHiddenSubscription.unsubscribe(); clearTimeout(this.resetTransitionTimeout); } diff --git a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html index a7fbd0066..85f64f564 100644 --- a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html +++ b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.html @@ -51,7 +51,7 @@

Latest blocks
  - + @@ -65,7 +65,7 @@
Adjustments
  - +
diff --git a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts index df4713374..6353ab8b8 100644 --- a/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts +++ b/frontend/src/app/components/mining-dashboard/mining-dashboard.component.ts @@ -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 { WebsocketService } from '../../services/websocket.service'; +import { StateService } from '../../services/state.service'; +import { EventType, NavigationStart, Router } from '@angular/router'; @Component({ selector: 'app-mining-dashboard', @@ -8,10 +10,12 @@ import { WebsocketService } from '../../services/websocket.service'; styleUrls: ['./mining-dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MiningDashboardComponent implements OnInit { +export class MiningDashboardComponent implements OnInit, AfterViewInit { constructor( private seoService: SeoService, private websocketService: WebsocketService, + private stateService: StateService, + private router: Router ) { this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Mining Dashboard`); } @@ -19,4 +23,15 @@ export class MiningDashboardComponent implements OnInit { ngOnInit(): void { 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(); + } + } + }); + } } diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.html b/frontend/src/app/components/pool-ranking/pool-ranking.component.html index 6ffcbf485..d5cf08aa5 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.html +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.html @@ -139,6 +139,8 @@ {{ miningStats.lastEstimatedHashrate}} {{ miningStats.miningUnits.hashrateUnit }} {{ miningStats.blockCount }} + + {{ miningStats.totalEmptyBlock }} ({{ miningStats.totalEmptyBlockRatio }}%) diff --git a/frontend/src/app/components/pool/pool-preview.component.ts b/frontend/src/app/components/pool/pool-preview.component.ts index 0431686d6..e03b73665 100644 --- a/frontend/src/app/components/pool/pool-preview.component.ts +++ b/frontend/src/app/components/pool/pool-preview.component.ts @@ -89,7 +89,7 @@ export class PoolPreviewComponent implements OnInit { 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) { this.openGraphService.waitOver('pool-img-' + this.slug); } diff --git a/frontend/src/app/components/pool/pool.component.ts b/frontend/src/app/components/pool/pool.component.ts index f2fc79ff2..edd5801fe 100644 --- a/frontend/src/app/components/pool/pool.component.ts +++ b/frontend/src/app/components/pool/pool.component.ts @@ -79,7 +79,7 @@ export class PoolComponent implements OnInit { poolStats.pool.regexes = regexes.slice(0, -3); return Object.assign({ - logo: `/resources/mining-pools/` + poolStats.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg' + logo: `/resources/mining-pools/` + poolStats.pool.slug + '.svg' }, poolStats); }) ); diff --git a/frontend/src/app/components/privacy-policy/privacy-policy.component.html b/frontend/src/app/components/privacy-policy/privacy-policy.component.html index fc93b9aa9..e0979ce24 100644 --- a/frontend/src/app/components/privacy-policy/privacy-policy.component.html +++ b/frontend/src/app/components/privacy-policy/privacy-policy.component.html @@ -43,7 +43,7 @@

TRUST YOUR OWN SELF-HOSTED MEMPOOL EXPLORER

-

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.

+

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.


diff --git a/frontend/src/app/components/search-form/search-form.component.html b/frontend/src/app/components/search-form/search-form.component.html index cdfcfe015..3fc03c83a 100644 --- a/frontend/src/app/components/search-form/search-form.component.html +++ b/frontend/src/app/components/search-form/search-form.component.html @@ -1,7 +1,7 @@
- +
diff --git a/frontend/src/app/components/search-form/search-form.component.scss b/frontend/src/app/components/search-form/search-form.component.scss index f3d2ee234..534bf0698 100644 --- a/frontend/src/app/components/search-form/search-form.component.scss +++ b/frontend/src/app/components/search-form/search-form.component.scss @@ -18,9 +18,10 @@ form { margin-top: 5px; - @media (min-width: 576px) { + @media (min-width: 564px) { margin-top: 0px; - margin-left: 8px; + margin-left: 5px; + margin-right: -5px; } @media (min-width: 992px) { width: 100%; diff --git a/frontend/src/app/components/search-form/search-form.component.ts b/frontend/src/app/components/search-form/search-form.component.ts index ab42fe1f7..0a794d1f5 100644 --- a/frontend/src/app/components/search-form/search-form.component.ts +++ b/frontend/src/app/components/search-form/search-form.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener, ElementRef } from '@angular/core'; 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 { StateService } from '../../services/state.service'; 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}$/; regexTransaction = /^([a-fA-F0-9]{64})(:\d+)?$/; regexBlockheight = /^[0-9]{1,9}$/; @@ -47,6 +47,8 @@ export class SearchFormComponent implements OnInit { this.handleKeyDown($event); } + @ViewChild('searchInput') searchInput: ElementRef; + constructor( private formBuilder: UntypedFormBuilder, private router: Router, @@ -55,11 +57,26 @@ export class SearchFormComponent implements OnInit { private electrsApiService: ElectrsApiService, private apiService: ApiService, private relativeUrlPipe: RelativeUrlPipe, - private elementRef: ElementRef, - ) { } + private elementRef: ElementRef + ) { + } ngOnInit(): void { 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({ searchText: ['', Validators.required], diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index 33770bb24..22e39b2de 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -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 { MarkBlockState, StateService } from '../../services/state.service'; import { specialBlocks } from '../../app.constants'; @@ -9,7 +9,7 @@ import { BlockExtended } from '../../interfaces/node-api.interface'; templateUrl: './start.component.html', styleUrls: ['./start.component.scss'], }) -export class StartComponent implements OnInit, OnDestroy { +export class StartComponent implements OnInit, OnDestroy, DoCheck { @Input() showLoadingIndicator = false; interval = 60; @@ -43,6 +43,7 @@ export class StartComponent implements OnInit, OnDestroy { pageIndex: number = 0; pages: any[] = []; pendingMark: number | null = null; + pendingOffset: number | null = null; lastUpdate: number = 0; lastMouseX: number; 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); } + ngDoCheck(): void { + if (this.pendingOffset != null) { + const offset = this.pendingOffset; + this.pendingOffset = null; + this.addConvertedScrollOffset(offset); + } + } + ngOnInit() { this.firstPageWidth = 40 + (this.blockWidth * this.dynamicBlocksAmount); this.blockCounterSubscription = this.stateService.blocks$.subscribe((blocks) => { @@ -429,6 +438,7 @@ export class StartComponent implements OnInit, OnDestroy { addConvertedScrollOffset(offset: number): void { if (!this.blockchainContainer?.nativeElement) { + this.pendingOffset = offset; return; } if (this.timeLtr) { diff --git a/frontend/src/app/components/trademark-policy/trademark-policy.component.html b/frontend/src/app/components/trademark-policy/trademark-policy.component.html index 4f9419642..3a3da15dd 100644 --- a/frontend/src/app/components/trademark-policy/trademark-policy.component.html +++ b/frontend/src/app/components/trademark-policy/trademark-policy.component.html @@ -7,7 +7,7 @@

Trademark Policy and Guidelines

-
The Mempool Open Source Project ™
+
The Mempool Open Source Project ®
Updated: July 19, 2021

@@ -304,7 +304,7 @@

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:

-

“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.”

+

“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.”

  • What to Do When You See Abuse
  • diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index a916ae193..ef34bf822 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -23,7 +23,7 @@ @@ -56,7 +56,9 @@ Peg-in - P2PK + P2PK + + @@ -182,12 +184,19 @@ - + + + + P2PK + + + +
    diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.scss b/frontend/src/app/components/transactions-list/transactions-list.component.scss index 5d6dd7d61..b80c4da4c 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.scss +++ b/frontend/src/app/components/transactions-list/transactions-list.component.scss @@ -149,6 +149,15 @@ h2 { font-family: monospace; } +.p2pk-address { + display: inline-block; + margin-left: 1em; + max-width: 100px; + @media (min-width: 576px) { + max-width: 200px + } +} + .grey-info-text { color:#6c757d; font-style: italic; diff --git a/frontend/src/app/dashboard/dashboard.component.html b/frontend/src/app/dashboard/dashboard.component.html index a9f6e3994..fdd2131fe 100644 --- a/frontend/src/app/dashboard/dashboard.component.html +++ b/frontend/src/app/dashboard/dashboard.component.html @@ -78,7 +78,7 @@
    Latest replacements
      - +
    @@ -112,7 +112,7 @@
    Latest blocks
      - +
    diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index aca3593d7..31fabd06f 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -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 { filter, map, scan, share, switchMap, tap } from 'rxjs/operators'; -import { BlockExtended, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; +import { catchError, filter, map, scan, share, switchMap, tap } from 'rxjs/operators'; +import { BlockExtended, OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.interface'; import { ApiService } from '../services/api.service'; import { StateService } from '../services/state.service'; @@ -31,7 +31,7 @@ interface MempoolStatsData { styleUrls: ['./dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class DashboardComponent implements OnInit, OnDestroy { +export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { featuredAssets$: Observable; network$: Observable; mempoolBlocksData$: Observable; @@ -57,6 +57,10 @@ export class DashboardComponent implements OnInit, OnDestroy { private seoService: SeoService ) { } + ngAfterViewInit(): void { + this.stateService.focusSearchInputDesktop(); + } + ngOnDestroy(): void { this.currencySubscription.unsubscribe(); this.websocketService.stopTrackRbfSummary(); @@ -155,7 +159,7 @@ export class DashboardComponent implements OnInit, OnDestroy { for (const block of blocks) { // @ts-ignore: Need to add an extra field for the template 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)); @@ -167,7 +171,11 @@ export class DashboardComponent implements OnInit, OnDestroy { this.mempoolStats$ = this.stateService.connectionState$ .pipe( filter((state) => state === 2), - switchMap(() => this.apiService.list2HStatistics$()), + switchMap(() => this.apiService.list2HStatistics$().pipe( + catchError((e) => { + return of(null); + }) + )), switchMap((mempoolStats) => { return merge( this.stateService.live2Chart$ @@ -182,10 +190,14 @@ export class DashboardComponent implements OnInit, OnDestroy { ); }), map((mempoolStats) => { - return { - mempool: mempoolStats, - weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])), - }; + if (mempoolStats) { + return { + mempool: mempoolStats, + weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])), + }; + } else { + return null; + } }), share(), ); diff --git a/frontend/src/app/docs/api-docs/api-docs.component.html b/frontend/src/app/docs/api-docs/api-docs.component.html index 9945a3f05..40a7ae486 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.html +++ b/frontend/src/app/docs/api-docs/api-docs.component.html @@ -10,8 +10,8 @@
    -

    mempool.space merely provides data about the Bitcoin network. It cannot help you with retrieving funds, confirming your transaction quicker, etc.

    For any such requests, you need to get in touch with the entity that helped make the transaction (wallet software, exchange company, etc).

    -

    mempool.space merely provides data about the Bitcoin network. It cannot help you with retrieving funds, confirming your transaction quicker, etc.

    For any such requests, you need to get in touch with the entity that helped make the transaction (wallet software, exchange company, etc).

    +

    mempool.space merely provides data about the Bitcoin network. It cannot help you with retrieving funds, wallet issues, etc.

    For any such requests, you need to get in touch with the entity that helped make the transaction (wallet software, exchange company, etc).

    +

    mempool.space merely provides data about the Bitcoin network. It cannot help you with retrieving funds, wallet issues, etc.

    For any such requests, you need to get in touch with the entity that helped make the transaction (wallet software, exchange company, etc).

    diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 2739d2b06..df19f7491 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -129,6 +129,22 @@ export interface Address { address: string; chain_stats: ChainStats; 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 { diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 2b434c44d..59dff8e90 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -110,6 +110,7 @@ export interface PoolInfo { regexes: string; // JSON array addresses: string; // JSON array emptyBlocks: number; + slug: string; } export interface PoolStat { pool: PoolInfo; @@ -174,7 +175,7 @@ export interface TransactionStripped { vsize: number; value: number; 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'; } diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 15d97fa8d..e0ecdfeda 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -89,7 +89,7 @@ export interface TransactionStripped { vsize: number; value: number; 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'; } diff --git a/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.html b/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.html index b5615324b..08a341de4 100644 --- a/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.html +++ b/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.html @@ -1,19 +1,43 @@
    - - - - - - - - - - - - - - - - -
    Starting balance{{ minStartingBalance | number : '1.0-0' }} - {{ maxStartingBalance | number : '1.0-0' }}?
    Closing balance{{ minClosingBalance | number : '1.0-0' }} - {{ maxClosingBalance | number : '1.0-0' }}?
    +
    +
    Starting balance
    +
    +
    {{ left.alias }}
    +
    {{ right.alias }}
    +
    +
    +
    + {{ minStartingBalance | number : '1.0-0' }} - {{ maxStartingBalance | number : '1.0-0' }} + {{ minStartingBalance | number : '1.0-0' }} +
    +
    + {{ channel.capacity - maxStartingBalance | number : '1.0-0' }} - {{ channel.capacity - minStartingBalance | number : '1.0-0' }} + {{ channel.capacity - maxStartingBalance | number : '1.0-0' }} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Closing balance
    +
    +
    + {{ minClosingBalance | number : '1.0-0' }} - {{ maxClosingBalance | number : '1.0-0' }} + {{ minClosingBalance | number : '1.0-0' }} +
    +
    + {{ channel.capacity - maxClosingBalance | number : '1.0-0' }} - {{ channel.capacity - minClosingBalance | number : '1.0-0' }} + {{ channel.capacity - maxClosingBalance | number : '1.0-0' }} +
    +
    +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.scss b/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.scss index a42871308..f55550eb3 100644 --- a/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.scss +++ b/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.scss @@ -6,4 +6,98 @@ .box { 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 + ) + } + } } \ No newline at end of file diff --git a/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.ts b/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.ts index 05cc31434..ef42464eb 100644 --- a/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.ts +++ b/frontend/src/app/lightning/channel/channel-close-box/channel-close-box.component.ts @@ -8,8 +8,8 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } f }) export class ChannelCloseBoxComponent implements OnChanges { @Input() channel: any; - @Input() local: any; - @Input() remote: any; + @Input() left: any; + @Input() right: any; showStartingBalance: boolean = false; showClosingBalance: boolean = false; @@ -18,29 +18,55 @@ export class ChannelCloseBoxComponent implements OnChanges { minClosingBalance: 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() { } ngOnChanges(changes: SimpleChanges): void { - if (this.channel && this.local && this.remote) { - this.showStartingBalance = (this.local.funding_balance || this.remote.funding_balance) && this.channel.funding_ratio; - this.showClosingBalance = this.local.closing_balance || this.remote.closing_balance; + let closingCapacity; + if (this.channel && this.left && this.right) { + 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.local.funding_balance) { + if (this.left.funding_balance) { this.minStartingBalance = this.channel.capacity; this.maxStartingBalance = this.channel.capacity; - } else if (this.remote.funding_balance) { + } else if (this.right.funding_balance) { this.minStartingBalance = 0; this.maxStartingBalance = 0; } } else { - this.minStartingBalance = clampRound(0, this.channel.capacity, this.local.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.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.right.funding_balance * this.channel.funding_ratio)); } - const closingCapacity = this.channel.capacity - this.channel.closing_fee; - this.minClosingBalance = clampRound(0, closingCapacity, this.local.closing_balance); - this.maxClosingBalance = clampRound(0, closingCapacity, closingCapacity - this.remote.closing_balance); + closingCapacity = this.channel.capacity - this.channel.closing_fee; + this.minClosingBalance = clampRound(0, closingCapacity, this.left.closing_balance); + this.maxClosingBalance = clampRound(0, closingCapacity, closingCapacity - this.right.closing_balance); // margin of error to account for 2 x 330 sat anchor outputs if (Math.abs(this.minClosingBalance - this.maxClosingBalance) <= 660) { @@ -50,6 +76,26 @@ export class ChannelCloseBoxComponent implements OnChanges { this.showStartingBalance = 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; } } diff --git a/frontend/src/app/lightning/channel/channel.component.html b/frontend/src/app/lightning/channel/channel.component.html index 2766f1d15..b9d9e09a4 100644 --- a/frontend/src/app/lightning/channel/channel.component.html +++ b/frontend/src/app/lightning/channel/channel.component.html @@ -75,14 +75,14 @@
    -
    -
    + +
    diff --git a/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html index 123069479..f7d318073 100644 --- a/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html +++ b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html @@ -61,7 +61,7 @@
    Liquidity Ranking
      - +
    @@ -75,7 +75,7 @@
    Connectivity Ranking
      - +
    diff --git a/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.ts b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.ts index 6fa4b454c..e58d5f124 100644 --- a/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.ts +++ b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { share } from 'rxjs/operators'; import { INodesRanking } from '../../interfaces/node-api.interface'; @@ -12,7 +12,7 @@ import { LightningApiService } from '../lightning-api.service'; styleUrls: ['./lightning-dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class LightningDashboardComponent implements OnInit { +export class LightningDashboardComponent implements OnInit, AfterViewInit { statistics$: Observable; nodesRanking$: Observable; officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE; @@ -30,4 +30,7 @@ export class LightningDashboardComponent implements OnInit { this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share()); } + ngAfterViewInit(): void { + this.stateService.focusSearchInputDesktop(); + } } diff --git a/frontend/src/app/lightning/nodes-map/nodes-map.component.scss b/frontend/src/app/lightning/nodes-map/nodes-map.component.scss index d49b68957..a2f62e9c5 100644 --- a/frontend/src/app/lightning/nodes-map/nodes-map.component.scss +++ b/frontend/src/app/lightning/nodes-map/nodes-map.component.scss @@ -14,7 +14,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.scss b/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.scss index bb8f2cd87..0e6fb056d 100644 --- a/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.scss +++ b/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.scss @@ -25,7 +25,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/lightning/nodes-rankings-dashboard/nodes-rankings-dashboard.component.html b/frontend/src/app/lightning/nodes-rankings-dashboard/nodes-rankings-dashboard.component.html index 1623d917e..ef23bc104 100644 --- a/frontend/src/app/lightning/nodes-rankings-dashboard/nodes-rankings-dashboard.component.html +++ b/frontend/src/app/lightning/nodes-rankings-dashboard/nodes-rankings-dashboard.component.html @@ -8,7 +8,7 @@
    Liquidity Ranking
      + style="vertical-align: text-top; font-size: 13px; color: #4a68b9"> @@ -22,7 +22,7 @@
    Connectivity Ranking
      + style="vertical-align: text-top; font-size: 13px; color: #4a68b9"> @@ -36,7 +36,7 @@
    Oldest nodes
      + style="vertical-align: text-top; font-size: 13px; color: #4a68b9"> diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss index 0d692a6c8..c885e4839 100644 --- a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss @@ -25,7 +25,8 @@ flex-direction: column; padding: 0px 15px; width: 100%; - height: calc(100vh - 250px); + height: calc(100vh - 225px); + min-height: 400px; @media (min-width: 992px) { height: calc(100vh - 150px); } diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index c87018741..d63d49f68 100644 --- a/frontend/src/app/services/electrs-api.service.ts +++ b/frontend/src/app/services/electrs-api.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { Transaction, Address, Outspend, Recent, Asset } from '../interfaces/electrs.interface'; +import { Observable, from, of, switchMap } from 'rxjs'; +import { Transaction, Address, Outspend, Recent, Asset, ScriptHash } from '../interfaces/electrs.interface'; import { StateService } from './state.service'; import { BlockExtended } from '../interfaces/node-api.interface'; +import { calcScriptHash$ } from '../bitcoin.utils'; @Injectable({ providedIn: 'root' @@ -65,6 +66,25 @@ export class ElectrsApiService { return this.httpClient.get
    (this.apiBaseUrl + this.apiBasePath + '/api/address/' + address); } + getPubKeyAddress$(pubkey: string): Observable
    { + 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 { + return from(calcScriptHash$(script)).pipe( + switchMap(scriptHash => this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash)) + ); + } + getAddressTransactions$(address: string, txid?: string): Observable { let params = new HttpParams(); if (txid) { @@ -73,6 +93,16 @@ export class ElectrsApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); } + getScriptHashTransactions$(script: string, txid?: string): Observable { + let params = new HttpParams(); + if (txid) { + params = params.append('after_txid', txid); + } + return from(calcScriptHash$(script)).pipe( + switchMap(scriptHash => this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash + '/txs', { params })), + ); + } + getAsset$(assetId: string): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); } diff --git a/frontend/src/app/services/mining.service.ts b/frontend/src/app/services/mining.service.ts index 63257fa74..96723921e 100644 --- a/frontend/src/app/services/mining.service.ts +++ b/frontend/src/app/services/mining.service.ts @@ -96,7 +96,7 @@ export class MiningService { share: parseFloat((poolStat.blockCount / stats.blockCount * 100).toFixed(2)), lastEstimatedHashrate: (poolStat.blockCount / stats.blockCount * stats.lastEstimatedHashrate / hashrateDivider).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 }; }); diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 5ebca9ba1..9ab8a7e93 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -7,6 +7,7 @@ import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; import { filter, map, scan, shareReplay } from 'rxjs/operators'; import { StorageService } from './storage.service'; +import { hasTouchScreen } from '../shared/pipes/bytes-pipe/utils'; export interface MarkBlockState { blockHeight?: number; @@ -113,6 +114,7 @@ export class StateService { mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition}>(); blockTransactions$ = new Subject(); isLoadingWebSocket$ = new ReplaySubject(1); + isLoadingMempool$ = new BehaviorSubject(true); vbytesPerSecond$ = new ReplaySubject(1); previousRetarget$ = new ReplaySubject(1); backendInfo$ = new ReplaySubject(1); @@ -138,6 +140,8 @@ export class StateService { fiatCurrency$: BehaviorSubject; rateUnits$: BehaviorSubject; + searchFocus$: Subject = new Subject(); + constructor( @Inject(PLATFORM_ID) private platformId: any, @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.blocksSubject$.next(this.blocks); } + + focusSearchInputDesktop() { + if (!hasTouchScreen()) { + this.searchFocus$.next(true); + } + } } diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index f32f772ac..4bd20e987 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -113,7 +113,7 @@ export class WebsocketService { this.stateService.connectionState$.next(2); } - if (this.stateService.connectionState$.value === 1) { + if (this.stateService.connectionState$.value !== 2) { this.stateService.connectionState$.next(2); } @@ -368,6 +368,11 @@ export class WebsocketService { if (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) { diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html index 8e5279a94..2b30714d1 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.html +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html @@ -2,7 +2,10 @@
    -
    The Mempool Open Source Project
    +

    Explore the full Bitcoin ecosystem