mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
bootstrap 5: fix conflicts
This commit is contained in:
commit
8fbfffa6d6
455 changed files with 27599 additions and 29172 deletions
145
.github/workflows/backend-integration.yml
vendored
Normal file
145
.github/workflows/backend-integration.yml
vendored
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
name: Backend Integration Tests with MariaDB
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, review_requested, synchronize]
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
backend-integration:
|
||||
if: "(github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')) || github.event_name == 'push'"
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["22.14.0"]
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Backend Integration Tests - node ${{ matrix.node }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: ${{ matrix.node }}/integration
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '${{ matrix.node }}/integration/backend/package-lock.json'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.node }}/integration/backend/node_modules
|
||||
key: ${{ runner.os }}-backend-integration-node-${{ matrix.node }}-${{ hashFiles('${{ matrix.node }}/integration/backend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-backend-integration-node-${{ matrix.node }}-
|
||||
${{ runner.os }}-backend-integration-
|
||||
|
||||
- name: Read rust-toolchain file from repository
|
||||
id: gettoolchain
|
||||
run: echo "::set-output name=toolchain::$(cat ./rust/gbt/rust-toolchain)"
|
||||
working-directory: ${{ matrix.node }}/integration
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
${{ matrix.node }}/integration/rust/gbt/target/
|
||||
key: ${{ runner.os }}-cargo-integration-${{ hashFiles('${{ matrix.node }}/integration/rust/gbt/**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-integration-
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install ${{ steps.gettoolchain.outputs.toolchain }} Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921
|
||||
with:
|
||||
toolchain: ${{ steps.gettoolchain.outputs.toolchain }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ${{ matrix.node }}/integration/backend
|
||||
|
||||
- name: Build backend
|
||||
run: npm run build
|
||||
working-directory: ${{ matrix.node }}/integration/backend
|
||||
|
||||
- name: Verify config file exists
|
||||
run: |
|
||||
ls -la mempool-config.test.json
|
||||
echo "Current directory: ${PWD}"
|
||||
echo "Config file will be: ${{ github.workspace }}/${{ matrix.node }}/integration/backend/mempool-config.test.json"
|
||||
test -f mempool-config.test.json || (echo "ERROR: Config file not found!" && exit 1)
|
||||
working-directory: ${{ matrix.node }}/integration/backend
|
||||
|
||||
- name: Run integration tests (DB auto-starts via Jest)
|
||||
run: |
|
||||
echo "MEMPOOL_CONFIG_FILE=$MEMPOOL_CONFIG_FILE"
|
||||
npm run test:integration
|
||||
working-directory: ${{ matrix.node }}/integration/backend
|
||||
env:
|
||||
MEMPOOL_CONFIG_FILE: ${{ github.workspace }}/${{ matrix.node }}/integration/backend/mempool-config.test.json
|
||||
|
||||
- name: Start MariaDB for server test
|
||||
run: docker compose -f docker-compose.test.yml up -d
|
||||
working-directory: ${{ matrix.node }}/integration/backend
|
||||
|
||||
- name: Wait for MariaDB
|
||||
run: |
|
||||
echo "Waiting for MariaDB to be ready..."
|
||||
for i in {1..30}; do
|
||||
if docker compose -f docker-compose.test.yml exec -T db-test mysqladmin ping -h localhost -u mempool_test -pmempool_test --silent 2>/dev/null; then
|
||||
echo "MariaDB is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Attempt $i/30..."
|
||||
sleep 2
|
||||
done
|
||||
sleep 3
|
||||
working-directory: ${{ matrix.node }}/integration/backend
|
||||
|
||||
- name: Start backend server and verify connectivity
|
||||
run: |
|
||||
# Start server in background
|
||||
node dist/index.js &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to start
|
||||
echo "Waiting for server to start..."
|
||||
sleep 10
|
||||
|
||||
# Check if server is still running
|
||||
if ps -p $SERVER_PID > /dev/null 2>&1; then
|
||||
echo "Server started successfully and connected to database!"
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
exit 0
|
||||
else
|
||||
echo "Server failed to start or exited prematurely"
|
||||
exit 1
|
||||
fi
|
||||
working-directory: ${{ matrix.node }}/integration/backend
|
||||
env:
|
||||
MEMPOOL_CONFIG_FILE: ${{ github.workspace }}/${{ matrix.node }}/integration/backend/mempool-config.test.json
|
||||
|
||||
- name: Cleanup containers
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.test.yml down -v
|
||||
working-directory: ${{ matrix.node }}/integration/backend
|
||||
|
||||
- name: Display logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== MariaDB logs ==="
|
||||
docker compose -f docker-compose.test.yml logs db-test || true
|
||||
working-directory: ${{ matrix.node }}/integration/backend
|
||||
|
||||
63
.github/workflows/ci.yml
vendored
63
.github/workflows/ci.yml
vendored
|
|
@ -29,16 +29,41 @@ jobs:
|
|||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '${{ matrix.node }}/${{ matrix.flavor }}/backend/package-lock.json'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.node }}/${{ matrix.flavor }}/backend/node_modules
|
||||
key: ${{ runner.os }}-backend-${{ matrix.flavor }}-node-${{ matrix.node }}-${{ hashFiles('${{ matrix.node }}/${{ matrix.flavor }}/backend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-backend-${{ matrix.flavor }}-node-${{ matrix.node }}-
|
||||
${{ runner.os }}-backend-${{ matrix.flavor }}-
|
||||
|
||||
- name: Read rust-toolchain file from repository
|
||||
id: gettoolchain
|
||||
run: echo "::set-output name=toolchain::$(cat ./rust/gbt/rust-toolchain)"
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
${{ matrix.node }}/${{ matrix.flavor }}/rust/gbt/target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.flavor }}-${{ hashFiles('${{ matrix.node }}/${{ matrix.flavor }}/rust/gbt/**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.flavor }}-
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install ${{ steps.gettoolchain.outputs.toolchain }} Rust toolchain
|
||||
# Latest version available on this commit is 1.71.1
|
||||
# Commit date is Aug 3, 2023
|
||||
uses: dtolnay/rust-toolchain@d8352f6b1d2e870bc5716e7a6d9b65c4cc244a1a
|
||||
uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921
|
||||
with:
|
||||
toolchain: ${{ steps.gettoolchain.outputs.toolchain }}
|
||||
|
||||
|
|
@ -69,6 +94,9 @@ jobs:
|
|||
|
||||
cache:
|
||||
name: "Cache assets for builds"
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["22.14.0"]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -81,6 +109,17 @@ jobs:
|
|||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'assets/frontend/package-lock.json'
|
||||
|
||||
- name: Cache node modules for frontend
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: assets/frontend/node_modules
|
||||
key: ${{ runner.os }}-cache-frontend-node-${{ matrix.node }}-${{ hashFiles('assets/frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cache-frontend-node-${{ matrix.node }}-
|
||||
${{ runner.os }}-cache-frontend-
|
||||
|
||||
- name: Install (Prod dependencies only)
|
||||
run: npm ci --omit=dev --omit=optional
|
||||
|
|
@ -180,6 +219,17 @@ jobs:
|
|||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '${{ matrix.node }}/${{ matrix.flavor }}/frontend/package-lock.json'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.node }}/${{ matrix.flavor }}/frontend/node_modules
|
||||
key: ${{ runner.os }}-frontend-${{ matrix.flavor }}-node-${{ matrix.node }}-${{ hashFiles('${{ matrix.node }}/${{ matrix.flavor }}/frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-frontend-${{ matrix.flavor }}-node-${{ matrix.node }}-
|
||||
${{ runner.os }}-frontend-${{ matrix.flavor }}-
|
||||
|
||||
- name: Install (Prod dependencies only)
|
||||
run: npm ci --omit=dev --omit=optional
|
||||
|
|
@ -270,6 +320,15 @@ jobs:
|
|||
cache: "npm"
|
||||
cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
|
||||
|
||||
- name: Cache node modules for e2e
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.module }}/frontend/node_modules
|
||||
key: ${{ runner.os }}-e2e-${{ matrix.module }}-node-22-${{ hashFiles('${{ matrix.module }}/frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-e2e-${{ matrix.module }}-node-22-
|
||||
${{ runner.os }}-e2e-${{ matrix.module }}-
|
||||
|
||||
- name: Restore cached mining pool assets
|
||||
continue-on-error: true
|
||||
id: cache-mining-pool-restore
|
||||
|
|
@ -405,4 +464,4 @@ jobs:
|
|||
- name: Validate JSON syntax
|
||||
run: |
|
||||
cat mempool-config.json | jq
|
||||
working-directory: docker/docker/backend
|
||||
working-directory: docker/docker/backend
|
||||
|
|
|
|||
|
|
@ -10,12 +10,18 @@ on:
|
|||
tags:
|
||||
- v[0-9]+.[0-9]+.[0-9]+
|
||||
- v[0-9]+.[0-9]+.[0-9]+-*
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Run on tag pushes OR on PRs that have the "docker" label
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'docker'))
|
||||
strategy:
|
||||
matrix:
|
||||
service:
|
||||
|
|
@ -56,15 +62,20 @@ jobs:
|
|||
sudo systemctl restart docker
|
||||
sudo df -h | grep docker
|
||||
|
||||
- name: Set env variables
|
||||
# Only for tag pushes: use the Git tag as TAG
|
||||
- name: Set TAG from pushed tag
|
||||
if: github.event_name == 'push'
|
||||
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||
|
||||
- name: Show set environment variables
|
||||
run: |
|
||||
printf " TAG: %s\n" "$TAG"
|
||||
|
||||
- name: Add SHORT_SHA env property with commit short sha
|
||||
run: echo "SHORT_SHA=`echo ${GITHUB_SHA} | cut -c1-8`" >> $GITHUB_ENV
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
SHA="${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
SHA="${GITHUB_SHA}"
|
||||
fi
|
||||
echo "SHORT_SHA=${SHA:0:8}" >> $GITHUB_ENV
|
||||
|
||||
|
||||
- name: Login to Docker for building
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
|
@ -72,6 +83,22 @@ jobs:
|
|||
- name: Checkout project
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# For PRs: use package.json version + short sha as TAG
|
||||
- name: Set TAG from service package.json for pull requests
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
if [ "${{ matrix.service }}" = "frontend" ]; then
|
||||
VERSION=$(jq -r '.version' frontend/package.json)
|
||||
else
|
||||
VERSION=$(jq -r '.version' backend/package.json)
|
||||
fi
|
||||
echo "TAG=v${VERSION}-${SHORT_SHA}" >> $GITHUB_ENV
|
||||
|
||||
- name: Show set environment variables
|
||||
run: |
|
||||
printf " TAG: %s\n" "$TAG"
|
||||
printf " SHORT_SHA: %s\n" "$SHORT_SHA"
|
||||
|
||||
- name: Init repo for Dockerization
|
||||
run: docker/init.sh "$TAG"
|
||||
|
||||
|
|
@ -117,10 +144,11 @@ jobs:
|
|||
|
||||
tag-latest:
|
||||
needs: build
|
||||
if: ${{ needs.build.result == 'success' }}
|
||||
# Only for successful *tag pushes* and only for "plain" versions (no '-')
|
||||
if: ${{ needs.build.result == 'success' && github.event_name == 'push' && !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
name: Tag images as latest
|
||||
name: Tag release build as latest
|
||||
strategy:
|
||||
matrix:
|
||||
service:
|
||||
|
|
@ -145,8 +173,8 @@ jobs:
|
|||
- name: Login to Docker Hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Tag multi-arch image as latest for ${{ matrix.service }}
|
||||
- name: Tag as latest for ${{ matrix.service }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \
|
||||
${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG
|
||||
${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG
|
||||
84
.github/workflows/project-review-status.yml
vendored
Normal file
84
.github/workflows/project-review-status.yml
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Workflow: Automatically set project status to "Review Needed" when a reviewer is requested
|
||||
name: Set Project Status on Review Request
|
||||
|
||||
# Trigger: Runs whenever a reviewer is requested on a pull request
|
||||
on:
|
||||
pull_request:
|
||||
types: [review_requested]
|
||||
|
||||
jobs:
|
||||
update-project-status:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update Project Status to Review Needed
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
# Use the PAT stored in repository secrets (has project write access)
|
||||
github-token: ${{ secrets.PROJECT_TOKEN }}
|
||||
script: |
|
||||
// GraphQL query to find the PR's project items
|
||||
// This fetches all projects the PR is linked to
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
projectItems(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
project {
|
||||
number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Execute the query with current repo/PR context
|
||||
const result = await github.graphql(query, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pr: context.payload.pull_request.number
|
||||
});
|
||||
|
||||
// Find the project item that belongs to project #8
|
||||
const projectItems = result.repository.pullRequest.projectItems.nodes;
|
||||
const projectItem = projectItems.find(item => item.project.number === 8);
|
||||
|
||||
// Exit early if PR isn't in project #8
|
||||
if (!projectItem) {
|
||||
console.log('PR is not in project #8, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
// GraphQL mutation to update the Status field
|
||||
const mutation = `
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: { singleSelectOptionId: $optionId }
|
||||
}
|
||||
) {
|
||||
projectV2Item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Execute the mutation using IDs stored in repository variables
|
||||
// PROJECT_ID: The project's unique identifier
|
||||
// STATUS_FIELD_ID: The "Status" field's unique identifier
|
||||
// REVIEW_NEEDED_OPTION_ID: The "Review Needed" option's unique identifier
|
||||
await github.graphql(mutation, {
|
||||
projectId: "${{ secrets.PROJECT_ID }}",
|
||||
itemId: projectItem.id,
|
||||
fieldId: "${{ secrets.STATUS_FIELD_ID }}",
|
||||
optionId: "${{ secrets.REVIEW_NEEDED_OPTION_ID }}"
|
||||
});
|
||||
|
||||
console.log('Successfully updated project status to Review Needed');
|
||||
21
backend/docker-compose.test.yml
Normal file
21
backend/docker-compose.test.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
version: "3.7"
|
||||
|
||||
services:
|
||||
db-test:
|
||||
image: mariadb:10.5.21
|
||||
environment:
|
||||
MYSQL_DATABASE: "mempool_test"
|
||||
MYSQL_USER: "mempool_test"
|
||||
MYSQL_PASSWORD: "mempool_test"
|
||||
MYSQL_ROOT_PASSWORD: "admin"
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
ports:
|
||||
- "33306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mempool_test", "-pmempool_test"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
tmpfs:
|
||||
- /var/lib/mysql
|
||||
|
||||
|
|
@ -16,5 +16,9 @@ const config: Config.InitialOptions = {
|
|||
setupFiles: [
|
||||
"./testSetup.ts",
|
||||
],
|
||||
testPathIgnorePatterns: [
|
||||
"/node_modules/",
|
||||
"/__integration_tests__/",
|
||||
],
|
||||
}
|
||||
export default config;
|
||||
|
|
|
|||
21
backend/jest.integration.config.ts
Normal file
21
backend/jest.integration.config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Config } from "@jest/types"
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
verbose: true,
|
||||
automock: false,
|
||||
collectCoverage: false,
|
||||
coverageProvider: "v8",
|
||||
testMatch: [
|
||||
"**/__integration_tests__/**/*.test.ts"
|
||||
],
|
||||
globalSetup: "./jest.integration.setup.ts", // Start database before all tests
|
||||
setupFiles: [
|
||||
"./testSetup.integration.ts",
|
||||
],
|
||||
globalTeardown: "./jest.integration.teardown.ts", // Stop database after all tests
|
||||
maxWorkers: 1, // Force sequential execution
|
||||
}
|
||||
export default config;
|
||||
|
||||
72
backend/jest.integration.setup.ts
Normal file
72
backend/jest.integration.setup.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Setup that runs BEFORE setupFiles
|
||||
// This ensures MEMPOOL_CONFIG_FILE is set before any modules are loaded
|
||||
import * as path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
// Set the config file path if not already set
|
||||
if (!process.env.MEMPOOL_CONFIG_FILE) {
|
||||
process.env.MEMPOOL_CONFIG_FILE = path.join(__dirname, 'mempool-config.test.json');
|
||||
}
|
||||
|
||||
// Helper to get docker compose command (v1 or v2)
|
||||
function getDockerComposeCmd(): string {
|
||||
try {
|
||||
execSync('docker compose version', { stdio: 'pipe' });
|
||||
return 'docker compose';
|
||||
} catch {
|
||||
try {
|
||||
execSync('docker-compose version', { stdio: 'pipe' });
|
||||
return 'docker-compose';
|
||||
} catch {
|
||||
throw new Error('Neither "docker compose" nor "docker-compose" is available');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the Docker test database container
|
||||
module.exports = async () => {
|
||||
// Skip if SKIP_DB_SETUP is set (e.g., when test-with-db.sh manages the database)
|
||||
if (process.env.SKIP_DB_SETUP) {
|
||||
console.log('Skipping database setup (managed externally)');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting test database container...');
|
||||
try {
|
||||
const composeFile = path.join(__dirname, 'docker-compose.test.yml');
|
||||
const dockerComposeCmd = getDockerComposeCmd();
|
||||
|
||||
// Start the container
|
||||
execSync(`${dockerComposeCmd} -f "${composeFile}" up -d`, {
|
||||
stdio: 'inherit',
|
||||
cwd: __dirname
|
||||
});
|
||||
|
||||
// Wait for database to be ready
|
||||
console.log('Waiting for database to be ready...');
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
execSync(`${dockerComposeCmd} -f "${composeFile}" exec -T db-test mysqladmin ping -h localhost -u mempool_test -pmempool_test --silent`, {
|
||||
cwd: __dirname,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
console.log('Database is ready!');
|
||||
break;
|
||||
} catch (e) {
|
||||
attempts++;
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error('Database did not start in time');
|
||||
}
|
||||
// Wait 1 second before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start test database:', error instanceof Error ? error.message : error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
102
backend/jest.integration.teardown.ts
Normal file
102
backend/jest.integration.teardown.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import DB from './src/database';
|
||||
import logger from './src/logger';
|
||||
import mempool from './src/api/mempool';
|
||||
import { execSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
|
||||
// Helper to get docker compose command (v1 or v2)
|
||||
function getDockerComposeCmd(): string {
|
||||
try {
|
||||
execSync('docker compose version', { stdio: 'pipe' });
|
||||
return 'docker compose';
|
||||
} catch {
|
||||
try {
|
||||
execSync('docker-compose version', { stdio: 'pipe' });
|
||||
return 'docker-compose';
|
||||
} catch {
|
||||
throw new Error('Neither "docker compose" nor "docker-compose" is available');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async () => {
|
||||
try {
|
||||
// Final cleanup after all tests
|
||||
const tables = [
|
||||
'blocks_audits',
|
||||
'blocks_summaries',
|
||||
'blocks_prices',
|
||||
'blocks_templates',
|
||||
'cpfp_clusters',
|
||||
'blocks',
|
||||
'difficulty_adjustments',
|
||||
'hashrates',
|
||||
'prices',
|
||||
'node_records',
|
||||
'nodes_sockets',
|
||||
'nodes',
|
||||
'lightning_stats',
|
||||
'transactions',
|
||||
'elements_pegs',
|
||||
'federation_txos',
|
||||
'pools'
|
||||
];
|
||||
|
||||
await DB.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
// Use 'silent' error logging to avoid noise for optional tables that don't exist
|
||||
await DB.query(`TRUNCATE TABLE ${table}`, [], 'silent');
|
||||
} catch (e) {
|
||||
// Table might not exist - silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
await DB.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
|
||||
logger.info('Integration tests cleanup completed');
|
||||
|
||||
// Close the database connection pool to prevent Jest from hanging
|
||||
await DB.close();
|
||||
logger.info('Database connection pool closed');
|
||||
|
||||
// Clean up singleton resources that have timers or sockets
|
||||
mempool.destroy();
|
||||
logger.info('Mempool resources cleaned up');
|
||||
|
||||
// Close logger's UDP socket last (after all logging is done)
|
||||
logger.close();
|
||||
|
||||
// Stop and remove the Docker test database container
|
||||
// Skip if SKIP_DB_TEARDOWN is set (e.g., when test-with-db.sh manages the database)
|
||||
if (!process.env.SKIP_DB_TEARDOWN) {
|
||||
try {
|
||||
const composeFile = path.join(__dirname, 'docker-compose.test.yml');
|
||||
const dockerComposeCmd = getDockerComposeCmd();
|
||||
execSync(`${dockerComposeCmd} -f "${composeFile}" down -v`, {
|
||||
stdio: 'inherit',
|
||||
cwd: __dirname
|
||||
});
|
||||
console.log('Test database container stopped and removed');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop Docker container:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping Docker cleanup (managed externally)');
|
||||
}
|
||||
} catch (error) {
|
||||
// Use console.error since logger might be closed
|
||||
console.error('Failed to cleanup after integration tests:', error instanceof Error ? error.message : error);
|
||||
} finally {
|
||||
// Ensure we always try to close connections even if cleanup fails
|
||||
try {
|
||||
await DB.close();
|
||||
mempool.destroy();
|
||||
logger.close();
|
||||
} catch (e) {
|
||||
// Ignore errors on close
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
163
backend/mempool-config.test.json
Normal file
163
backend/mempool-config.test.json
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
{
|
||||
"MEMPOOL": {
|
||||
"OFFICIAL": false,
|
||||
"NETWORK": "mainnet",
|
||||
"BACKEND": "none",
|
||||
"ENABLED": true,
|
||||
"HTTP_PORT": 8998,
|
||||
"SPAWN_CLUSTER_PROCS": 0,
|
||||
"API_URL_PREFIX": "/api/v1/",
|
||||
"POLL_RATE_MS": 2000,
|
||||
"CACHE_DIR": "./cache",
|
||||
"CACHE_ENABLED": false,
|
||||
"CLEAR_PROTECTION_MINUTES": 20,
|
||||
"RECOMMENDED_FEE_PERCENTILE": 50,
|
||||
"BLOCK_WEIGHT_UNITS": 4000000,
|
||||
"INITIAL_BLOCKS_AMOUNT": 8,
|
||||
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||
"INDEXING_BLOCKS_AMOUNT": 11000,
|
||||
"BLOCKS_SUMMARIES_INDEXING": false,
|
||||
"GOGGLES_INDEXING": false,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": false,
|
||||
"EXTERNAL_ASSETS": [],
|
||||
"EXTERNAL_MAX_RETRY": 1,
|
||||
"EXTERNAL_RETRY_INTERVAL": 0,
|
||||
"USER_AGENT": "mempool",
|
||||
"STDOUT_LOG_MIN_PRIORITY": "debug",
|
||||
"AUTOMATIC_POOLS_UPDATE": false,
|
||||
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json",
|
||||
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
|
||||
"POOLS_UPDATE_DELAY": 604800,
|
||||
"AUDIT": false,
|
||||
"RUST_GBT": true,
|
||||
"LIMIT_GBT": false,
|
||||
"CPFP_INDEXING": false,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 6,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
"ALLOW_UNREACHABLE": true,
|
||||
"PRICE_UPDATES_PER_HOUR": 1,
|
||||
"MAX_TRACKED_ADDRESSES": 100,
|
||||
"UNIX_SOCKET_PATH": ""
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool",
|
||||
"TIMEOUT": 60000,
|
||||
"COOKIE": false,
|
||||
"COOKIE_PATH": "/path/to/bitcoin/.cookie",
|
||||
"DEBUG_LOG_PATH": "/path/to/bitcoin/debug.log"
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 50002,
|
||||
"TLS_ENABLED": true
|
||||
},
|
||||
"ESPLORA": {
|
||||
"REST_API_URL": "http://127.0.0.1:3000",
|
||||
"UNIX_SOCKET_PATH": "/tmp/esplora-bitcoin-mainnet",
|
||||
"BATCH_QUERY_BASE_SIZE": 1000,
|
||||
"RETRY_UNIX_SOCKET_AFTER": 30000,
|
||||
"REQUEST_TIMEOUT": 10000,
|
||||
"FALLBACK_TIMEOUT": 5000,
|
||||
"FALLBACK": [],
|
||||
"MAX_BEHIND_TIP": 2
|
||||
},
|
||||
"SECOND_CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool",
|
||||
"TIMEOUT": 60000,
|
||||
"COOKIE": false,
|
||||
"COOKIE_PATH": "/path/to/bitcoin/.cookie"
|
||||
},
|
||||
"DATABASE": {
|
||||
"ENABLED": true,
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 33306,
|
||||
"SOCKET": "",
|
||||
"DATABASE": "mempool_test",
|
||||
"USERNAME": "mempool_test",
|
||||
"PASSWORD": "mempool_test",
|
||||
"TIMEOUT": 180000,
|
||||
"PID_DIR": ""
|
||||
},
|
||||
"SYSLOG": {
|
||||
"ENABLED": false,
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 514,
|
||||
"MIN_PRIORITY": "info",
|
||||
"FACILITY": "local7"
|
||||
},
|
||||
"STATISTICS": {
|
||||
"ENABLED": true,
|
||||
"TX_PER_SECOND_SAMPLE_PERIOD": 150
|
||||
},
|
||||
"MAXMIND": {
|
||||
"ENABLED": false,
|
||||
"GEOLITE2_CITY": "/usr/local/share/GeoIP/GeoLite2-City.mmdb",
|
||||
"GEOLITE2_ASN": "/usr/local/share/GeoIP/GeoLite2-ASN.mmdb",
|
||||
"GEOIP2_ISP": "/usr/local/share/GeoIP/GeoIP2-ISP.mmdb"
|
||||
},
|
||||
"LIGHTNING": {
|
||||
"ENABLED": false,
|
||||
"BACKEND": "lnd",
|
||||
"STATS_REFRESH_INTERVAL": 600,
|
||||
"GRAPH_REFRESH_INTERVAL": 600,
|
||||
"LOGGER_UPDATE_INTERVAL": 30,
|
||||
"FORENSICS_INTERVAL": 43200,
|
||||
"FORENSICS_RATE_LIMIT": 20
|
||||
},
|
||||
"LND": {
|
||||
"TLS_CERT_PATH": "tls.cert",
|
||||
"MACAROON_PATH": "readonly.macaroon",
|
||||
"REST_API_URL": "https://localhost:8080",
|
||||
"TIMEOUT": 10000
|
||||
},
|
||||
"CLIGHTNING": {
|
||||
"SOCKET": "lightning-rpc"
|
||||
},
|
||||
"SOCKS5PROXY": {
|
||||
"ENABLED": false,
|
||||
"USE_ONION": true,
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 9050,
|
||||
"USERNAME": "",
|
||||
"PASSWORD": ""
|
||||
},
|
||||
"EXTERNAL_DATA_SERVER": {
|
||||
"MEMPOOL_API": "https://mempool.space/api/v1",
|
||||
"MEMPOOL_ONION": "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1",
|
||||
"LIQUID_API": "https://liquid.network/api/v1",
|
||||
"LIQUID_ONION": "http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1"
|
||||
},
|
||||
"REDIS": {
|
||||
"ENABLED": false,
|
||||
"UNIX_SOCKET_PATH": "/tmp/redis.sock",
|
||||
"BATCH_QUERY_BASE_SIZE": 5000
|
||||
},
|
||||
"REPLICATION": {
|
||||
"ENABLED": false,
|
||||
"AUDIT": false,
|
||||
"AUDIT_START_HEIGHT": 774000,
|
||||
"STATISTICS": false,
|
||||
"STATISTICS_START_TIME": 1481932800,
|
||||
"SERVERS": []
|
||||
},
|
||||
"MEMPOOL_SERVICES": {
|
||||
"API": "https://mempool.space/api/v1/services",
|
||||
"ACCELERATIONS": false
|
||||
},
|
||||
"STRATUM": {
|
||||
"ENABLED": false,
|
||||
"API": "http://localhost:1234"
|
||||
},
|
||||
"FIAT_PRICE": {
|
||||
"ENABLED": false,
|
||||
"PAID": false,
|
||||
"API_KEY": ""
|
||||
}
|
||||
}
|
||||
|
||||
8954
backend/package-lock.json
generated
8954
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -34,6 +34,8 @@
|
|||
"reindex-all-blocks": "npm run start-production --update-pools --reindex-blocks",
|
||||
"test": "./node_modules/.bin/jest --coverage",
|
||||
"test:ci": "CI=true ./node_modules/.bin/jest --coverage",
|
||||
"test:integration": "./node_modules/.bin/jest --config=jest.integration.config.ts --runInBand --forceExit",
|
||||
"test:with-db": "bash ./scripts/test-with-db.sh",
|
||||
"lint": "./node_modules/.bin/eslint . --ext .ts",
|
||||
"lint:fix": "./node_modules/.bin/eslint . --ext .ts --fix",
|
||||
"prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\""
|
||||
|
|
@ -41,12 +43,12 @@
|
|||
"dependencies": {
|
||||
"@mempool/electrum-client": "1.1.9",
|
||||
"@types/node": "^18.15.3",
|
||||
"axios": "1.11.0",
|
||||
"axios": "1.12.2",
|
||||
"bitcoinjs-lib": "~6.1.3",
|
||||
"crypto-js": "~4.2.0",
|
||||
"express": "~4.21.1",
|
||||
"express": "~4.22.1",
|
||||
"maxmind": "~4.3.11",
|
||||
"mysql2": "~3.14.1",
|
||||
"mysql2": "~3.16.0",
|
||||
"rust-gbt": "file:./rust-gbt",
|
||||
"redis": "^4.7.0",
|
||||
"socks-proxy-agent": "~7.0.0",
|
||||
|
|
@ -57,15 +59,19 @@
|
|||
"@types/compression": "^1.7.2",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/ws": "~8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.55.0",
|
||||
"@typescript-eslint/parser": "^5.55.0",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-jest": "^29.4.5",
|
||||
"ts-node": "^10.9.1"
|
||||
},
|
||||
"overrides": {
|
||||
"js-yaml": "^4.1.1",
|
||||
"glob": "^11.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
backend/scripts/debug-integration-tests.sh
Executable file
40
backend/scripts/debug-integration-tests.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Script to debug integration tests
|
||||
# Usage: ./scripts/debug-integration-tests.sh [test-file-pattern]
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Get the directory of this script
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
BACKEND_DIR="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
# Check if MariaDB is running
|
||||
if ! docker-compose -f docker-compose.test.yml ps | grep -q "Up"; then
|
||||
echo -e "${YELLOW}Starting MariaDB container...${NC}"
|
||||
docker-compose -f docker-compose.test.yml up -d
|
||||
|
||||
# Wait for MariaDB
|
||||
echo -e "${YELLOW}Waiting for MariaDB...${NC}"
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# Run tests with pattern if provided
|
||||
if [ -n "$1" ]; then
|
||||
echo -e "${GREEN}Running tests matching: $1${NC}"
|
||||
MEMPOOL_CONFIG_FILE="$BACKEND_DIR/mempool-config.test.json" npx jest --config=jest.integration.config.ts --runInBand --verbose "$1"
|
||||
else
|
||||
echo -e "${GREEN}Running all integration tests${NC}"
|
||||
MEMPOOL_CONFIG_FILE="$BACKEND_DIR/mempool-config.test.json" npm run test:integration
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Tests completed${NC}"
|
||||
|
||||
111
backend/scripts/test-with-db.sh
Executable file
111
backend/scripts/test-with-db.sh
Executable file
|
|
@ -0,0 +1,111 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Starting integration tests with MariaDB...${NC}"
|
||||
|
||||
# Get the directory of this script
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
BACKEND_DIR="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
# Detect docker compose command (v1 or v2)
|
||||
if docker compose version &> /dev/null; then
|
||||
DOCKER_COMPOSE="docker compose"
|
||||
elif docker-compose version &> /dev/null; then
|
||||
DOCKER_COMPOSE="docker-compose"
|
||||
else
|
||||
echo -e "${RED}Error: Neither 'docker compose' nor 'docker-compose' is available${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Using: ${DOCKER_COMPOSE}${NC}"
|
||||
|
||||
# Function to cleanup on exit
|
||||
cleanup() {
|
||||
echo -e "\n${YELLOW}Cleaning up...${NC}"
|
||||
# Kill the backend server if it's running
|
||||
if [ ! -z "$SERVER_PID" ]; then
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
fi
|
||||
# Stop containers and remove volumes, but don't fail on network errors
|
||||
$DOCKER_COMPOSE -f docker-compose.test.yml down -v 2>&1 | grep -v "Resource is still in use" || true
|
||||
}
|
||||
|
||||
# Set trap to cleanup on exit
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Stop any existing test containers
|
||||
echo -e "${YELLOW}Stopping any existing test containers...${NC}"
|
||||
$DOCKER_COMPOSE -f docker-compose.test.yml down -v 2>/dev/null || true
|
||||
|
||||
# Start MariaDB container
|
||||
echo -e "${GREEN}Starting MariaDB container...${NC}"
|
||||
$DOCKER_COMPOSE -f docker-compose.test.yml up -d
|
||||
|
||||
# Wait for MariaDB to be ready
|
||||
echo -e "${YELLOW}Waiting for MariaDB to be ready...${NC}"
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if $DOCKER_COMPOSE -f docker-compose.test.yml exec -T db-test mysqladmin ping -h localhost -u mempool_test -pmempool_test --silent 2>/dev/null; then
|
||||
echo -e "${GREEN}MariaDB is ready!${NC}"
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
echo -e "${YELLOW}Attempt $attempt/$max_attempts - waiting for MariaDB...${NC}"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo -e "${RED}MariaDB did not start in time${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Additional wait to ensure MariaDB is fully initialized
|
||||
sleep 3
|
||||
|
||||
# Build the backend
|
||||
echo -e "${GREEN}Building backend...${NC}"
|
||||
npm run build
|
||||
|
||||
# Run integration tests with absolute path to config
|
||||
# SKIP_DB_SETUP=1 and SKIP_DB_TEARDOWN=1 tell Jest that we're managing the database lifecycle
|
||||
echo -e "${GREEN}Running integration tests...${NC}"
|
||||
export MEMPOOL_CONFIG_FILE="$BACKEND_DIR/mempool-config.test.json"
|
||||
export SKIP_DB_SETUP=1
|
||||
export SKIP_DB_TEARDOWN=1
|
||||
npm run test:integration
|
||||
|
||||
# Start the backend server in the background
|
||||
# MEMPOOL_CONFIG_FILE is already exported above
|
||||
echo -e "${GREEN}Starting backend server...${NC}"
|
||||
node dist/index.js &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to start and verify connection
|
||||
echo -e "${YELLOW}Waiting for server to start and connect to database...${NC}"
|
||||
sleep 10
|
||||
|
||||
# Check if server is still running
|
||||
if ps -p $SERVER_PID > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}Server started successfully and connected to database!${NC}"
|
||||
|
||||
# Kill the server (it will be in the cleanup function too, but do it here as well)
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
SERVER_PID=""
|
||||
else
|
||||
echo -e "${RED}Server failed to start or exited prematurely${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}All tests passed successfully!${NC}"
|
||||
|
||||
134
backend/src/__integration_tests__/blocks-repository.test.ts
Normal file
134
backend/src/__integration_tests__/blocks-repository.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import BlocksRepository from '../repositories/BlocksRepository';
|
||||
import { setupTestDatabase, waitForDatabase, cleanupTestData, insertTestPool, insertTestBlock } from './test-helpers';
|
||||
|
||||
describe('BlocksRepository Integration Tests', () => {
|
||||
let defaultPoolId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
await waitForDatabase();
|
||||
await setupTestDatabase();
|
||||
}, 120000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestData();
|
||||
// Create a default pool for all blocks
|
||||
defaultPoolId = await insertTestPool({
|
||||
name: 'Unknown',
|
||||
slug: 'unknown',
|
||||
addresses: '[]',
|
||||
regexes: '[]'
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData();
|
||||
});
|
||||
|
||||
test('should insert and retrieve a block', async () => {
|
||||
const blockHash = '00000000000000000001a0e3e9b2e6d6e8a4b0e9b2e6d6e8a4b0e9b2e6d6e8a4';
|
||||
const height = 800000;
|
||||
|
||||
await insertTestBlock({
|
||||
height: height,
|
||||
hash: blockHash,
|
||||
blockTimestamp: new Date('2023-07-16T00:00:00Z'),
|
||||
size: 1500000,
|
||||
weight: 3999000,
|
||||
tx_count: 3000,
|
||||
difficulty: 53911173001054.59,
|
||||
poolId: defaultPoolId
|
||||
});
|
||||
|
||||
const block = await BlocksRepository.$getBlockByHeight(height);
|
||||
|
||||
expect(block).toBeDefined();
|
||||
expect(block!.height).toBe(height);
|
||||
expect(block!.id).toBe(blockHash);
|
||||
});
|
||||
|
||||
test('should get block by hash', async () => {
|
||||
const blockHash = '00000000000000000002b0e3e9b2e6d6e8a4b0e9b2e6d6e8a4b0e9b2e6d6e8a4';
|
||||
const height = 800001;
|
||||
|
||||
await insertTestBlock({
|
||||
height: height,
|
||||
hash: blockHash,
|
||||
tx_count: 2500,
|
||||
poolId: defaultPoolId
|
||||
});
|
||||
|
||||
const block = await BlocksRepository.$getBlockByHash(blockHash);
|
||||
|
||||
expect(block).toBeDefined();
|
||||
expect(block!.id).toBe(blockHash);
|
||||
expect(block!.height).toBe(height);
|
||||
});
|
||||
|
||||
test('should handle non-existent block', async () => {
|
||||
const block = await BlocksRepository.$getBlockByHeight(999999);
|
||||
expect(block).toBeNull();
|
||||
});
|
||||
|
||||
test('should check for missing blocks in range', async () => {
|
||||
// Insert blocks with a gap
|
||||
await insertTestBlock({
|
||||
height: 800100,
|
||||
hash: '0000000000000000000100000000000000000000000000000000000000000001',
|
||||
poolId: defaultPoolId
|
||||
});
|
||||
await insertTestBlock({
|
||||
height: 800102,
|
||||
hash: '0000000000000000000100000000000000000000000000000000000000000003',
|
||||
poolId: defaultPoolId
|
||||
});
|
||||
|
||||
const missingBlocks = await BlocksRepository.$getMissingBlocksBetweenHeights(800100, 800102);
|
||||
|
||||
expect(missingBlocks).toContain(800101);
|
||||
});
|
||||
|
||||
test('should get latest block height', async () => {
|
||||
await insertTestBlock({
|
||||
height: 800200,
|
||||
hash: '0000000000000000000200000000000000000000000000000000000000000001',
|
||||
poolId: defaultPoolId
|
||||
});
|
||||
await insertTestBlock({
|
||||
height: 800201,
|
||||
hash: '0000000000000000000200000000000000000000000000000000000000000002',
|
||||
poolId: defaultPoolId
|
||||
});
|
||||
|
||||
const height = await BlocksRepository.$mostRecentBlockHeight();
|
||||
|
||||
expect(height).toBe(800201);
|
||||
});
|
||||
|
||||
test('should handle block with pool association', async () => {
|
||||
// Insert a pool and get its auto-generated ID
|
||||
const testPoolId = await insertTestPool({
|
||||
name: 'Test Pool',
|
||||
slug: 'test-pool',
|
||||
addresses: '[]',
|
||||
regexes: '[]'
|
||||
});
|
||||
|
||||
const blockHash = '0000000000000000000300000000000000000000000000000000000000000001';
|
||||
await insertTestBlock({
|
||||
height: 800300,
|
||||
hash: blockHash,
|
||||
poolId: testPoolId
|
||||
});
|
||||
|
||||
const block = await BlocksRepository.$getBlockByHash(blockHash);
|
||||
|
||||
expect(block).toBeDefined();
|
||||
expect(block).not.toBeNull();
|
||||
// The pool should be populated with the test pool's data
|
||||
if (block && block.extras?.pool) {
|
||||
expect(block.extras.pool.name).toBe('Test Pool');
|
||||
expect(block.extras.pool.slug).toBe('test-pool');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import DB from '../database';
|
||||
import config from '../config';
|
||||
import { setupTestDatabase, waitForDatabase, getTestDatabaseConfig } from './test-helpers';
|
||||
|
||||
describe('Database Connection Integration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
// Wait for database to be ready
|
||||
await waitForDatabase();
|
||||
}, 60000);
|
||||
|
||||
test('should connect to the test database', async () => {
|
||||
const dbConfig = getTestDatabaseConfig();
|
||||
expect(dbConfig.enabled).toBe(true);
|
||||
expect(dbConfig.database).toBe('mempool_test');
|
||||
expect(dbConfig.port).toBe(33306);
|
||||
});
|
||||
|
||||
test('should execute a simple query', async () => {
|
||||
const [result] = await DB.query<any>('SELECT 1 as value');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe(1);
|
||||
});
|
||||
|
||||
test('should execute a query with parameters', async () => {
|
||||
const [result] = await DB.query<any>('SELECT ? as sum', [42]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].sum).toBe(42);
|
||||
});
|
||||
|
||||
test('should check database connection', async () => {
|
||||
await expect(DB.checkDbConnection()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('should handle query timeout configuration', async () => {
|
||||
expect(config.DATABASE.TIMEOUT).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should have correct database configuration', () => {
|
||||
expect(config.DATABASE.HOST).toBe('127.0.0.1');
|
||||
expect(config.DATABASE.USERNAME).toBe('mempool_test');
|
||||
expect(config.DATABASE.PASSWORD).toBe('mempool_test');
|
||||
expect(config.DATABASE.DATABASE).toBe('mempool_test');
|
||||
});
|
||||
});
|
||||
|
||||
97
backend/src/__integration_tests__/database-migration.test.ts
Normal file
97
backend/src/__integration_tests__/database-migration.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import DB from '../database';
|
||||
import { setupTestDatabase, waitForDatabase } from './test-helpers';
|
||||
|
||||
describe('Database Migration Integration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
await waitForDatabase();
|
||||
await setupTestDatabase();
|
||||
}, 120000);
|
||||
|
||||
test('should create state table', async () => {
|
||||
const [result] = await DB.query<any>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'mempool_test'
|
||||
AND table_name = 'state'`
|
||||
);
|
||||
expect(result[0].count).toBe(1);
|
||||
});
|
||||
|
||||
test('should have schema version in state table', async () => {
|
||||
const [result] = await DB.query<any>("SELECT number FROM state WHERE name = 'schema_version'");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].number).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should create blocks table', async () => {
|
||||
const [result] = await DB.query<any>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'mempool_test'
|
||||
AND table_name = 'blocks'`
|
||||
);
|
||||
expect(result[0].count).toBe(1);
|
||||
});
|
||||
|
||||
test('should create pools table', async () => {
|
||||
const [result] = await DB.query<any>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'mempool_test'
|
||||
AND table_name = 'pools'`
|
||||
);
|
||||
expect(result[0].count).toBe(1);
|
||||
});
|
||||
|
||||
test('should create hashrates table', async () => {
|
||||
const [result] = await DB.query<any>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'mempool_test'
|
||||
AND table_name = 'hashrates'`
|
||||
);
|
||||
expect(result[0].count).toBe(1);
|
||||
});
|
||||
|
||||
test('should create prices table', async () => {
|
||||
const [result] = await DB.query<any>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'mempool_test'
|
||||
AND table_name = 'prices'`
|
||||
);
|
||||
expect(result[0].count).toBe(1);
|
||||
});
|
||||
|
||||
test('blocks table should have required columns', async () => {
|
||||
const [columns] = await DB.query<any>(
|
||||
`SELECT COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'mempool_test'
|
||||
AND TABLE_NAME = 'blocks'`
|
||||
);
|
||||
|
||||
const columnNames = columns.map((col: any) => col.COLUMN_NAME);
|
||||
expect(columnNames).toContain('height');
|
||||
expect(columnNames).toContain('hash');
|
||||
expect(columnNames).toContain('blockTimestamp');
|
||||
expect(columnNames).toContain('size');
|
||||
expect(columnNames).toContain('weight');
|
||||
expect(columnNames).toContain('tx_count');
|
||||
});
|
||||
|
||||
test('pools table should have required columns', async () => {
|
||||
const [columns] = await DB.query<any>(
|
||||
`SELECT COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'mempool_test'
|
||||
AND TABLE_NAME = 'pools'`
|
||||
);
|
||||
|
||||
const columnNames = columns.map((col: any) => col.COLUMN_NAME);
|
||||
expect(columnNames).toContain('id');
|
||||
expect(columnNames).toContain('name');
|
||||
expect(columnNames).toContain('slug');
|
||||
});
|
||||
});
|
||||
|
||||
122
backend/src/__integration_tests__/pools-repository.test.ts
Normal file
122
backend/src/__integration_tests__/pools-repository.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import PoolsRepository from '../repositories/PoolsRepository';
|
||||
import { setupTestDatabase, waitForDatabase, cleanupTestData, insertTestPool } from './test-helpers';
|
||||
|
||||
describe('PoolsRepository Integration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
await waitForDatabase();
|
||||
await setupTestDatabase();
|
||||
}, 120000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestData();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData();
|
||||
});
|
||||
|
||||
test('should insert and retrieve a pool', async () => {
|
||||
const poolData = {
|
||||
name: 'Foundry USA',
|
||||
slug: 'foundryusa',
|
||||
link: 'https://foundrydigital.com',
|
||||
addresses: JSON.stringify(['bc1qxhmdufsvnuaaaer4ynz88fspdsxq2h9e9cetdj']),
|
||||
regexes: JSON.stringify(['/Foundry USA Pool/'])
|
||||
};
|
||||
|
||||
const poolId = await insertTestPool(poolData);
|
||||
expect(poolId).toBeGreaterThan(0);
|
||||
|
||||
const pools = await PoolsRepository.$getPools();
|
||||
const insertedPool = pools.find(p => p.id === poolId);
|
||||
|
||||
expect(insertedPool).toBeDefined();
|
||||
expect(insertedPool?.name).toBe(poolData.name);
|
||||
expect(insertedPool?.slug).toBe(poolData.slug);
|
||||
});
|
||||
|
||||
test('should get pool by slug', async () => {
|
||||
await insertTestPool({
|
||||
name: 'AntPool',
|
||||
slug: 'antpool',
|
||||
link: 'https://antpool.com'
|
||||
});
|
||||
|
||||
const pool = await PoolsRepository.$getPool('antpool');
|
||||
|
||||
expect(pool).toBeDefined();
|
||||
expect(pool!.name).toBe('AntPool');
|
||||
expect(pool!.slug).toBe('antpool');
|
||||
});
|
||||
|
||||
test('should get all pools', async () => {
|
||||
await insertTestPool({
|
||||
name: 'Pool 1',
|
||||
slug: 'pool-1'
|
||||
});
|
||||
await insertTestPool({
|
||||
name: 'Pool 2',
|
||||
slug: 'pool-2'
|
||||
});
|
||||
await insertTestPool({
|
||||
name: 'Pool 3',
|
||||
slug: 'pool-3'
|
||||
});
|
||||
|
||||
const pools = await PoolsRepository.$getPools();
|
||||
|
||||
expect(pools.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('should handle pool with addresses', async () => {
|
||||
const addresses = ['bc1qtest1', 'bc1qtest2', '3TestAddress'];
|
||||
await insertTestPool({
|
||||
name: 'Multi Address Pool',
|
||||
slug: 'multi-address-pool',
|
||||
addresses: JSON.stringify(addresses)
|
||||
});
|
||||
|
||||
const pool = await PoolsRepository.$getPool('multi-address-pool', false);
|
||||
|
||||
expect(pool).toBeDefined();
|
||||
const poolAddresses = JSON.parse(pool!.addresses);
|
||||
expect(poolAddresses).toHaveLength(3);
|
||||
expect(poolAddresses).toContain('bc1qtest1');
|
||||
});
|
||||
|
||||
test('should handle pool with regexes', async () => {
|
||||
const regexes = ['/Pool Name/', '/Alternative Name/'];
|
||||
await insertTestPool({
|
||||
name: 'Regex Pool',
|
||||
slug: 'regex-pool',
|
||||
regexes: JSON.stringify(regexes)
|
||||
});
|
||||
|
||||
const pool = await PoolsRepository.$getPool('regex-pool', false);
|
||||
|
||||
expect(pool).toBeDefined();
|
||||
const poolRegexes = JSON.parse(pool!.regexes);
|
||||
expect(poolRegexes).toHaveLength(2);
|
||||
expect(poolRegexes[0]).toBe('/Pool Name/');
|
||||
});
|
||||
|
||||
test('should handle non-existent pool', async () => {
|
||||
const pool = await PoolsRepository.$getPool('non-existent-pool-slug');
|
||||
expect(pool).toBeNull();
|
||||
});
|
||||
|
||||
test('should update pool information', async () => {
|
||||
const poolDbId = await insertTestPool({
|
||||
name: 'Original Pool Name',
|
||||
slug: 'original-pool'
|
||||
});
|
||||
|
||||
// Update the pool name
|
||||
await PoolsRepository.$renameMiningPool(poolDbId, 'updated-pool', 'Updated Pool Name');
|
||||
|
||||
const pool = await PoolsRepository.$getPool('updated-pool');
|
||||
expect(pool).toBeDefined();
|
||||
expect(pool!.name).toBe('Updated Pool Name');
|
||||
});
|
||||
});
|
||||
|
||||
190
backend/src/__integration_tests__/test-helpers.ts
Normal file
190
backend/src/__integration_tests__/test-helpers.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import DB from '../database';
|
||||
import config from '../config';
|
||||
import logger from '../logger';
|
||||
import databaseMigration from '../api/database-migration';
|
||||
|
||||
/**
|
||||
* Initialize the test database with schema migrations
|
||||
*/
|
||||
export async function setupTestDatabase(): Promise<void> {
|
||||
try {
|
||||
await DB.checkDbConnection();
|
||||
await databaseMigration.$initializeOrMigrateDatabase();
|
||||
} catch (error) {
|
||||
logger.err('Failed to setup test database: ' + (error instanceof Error ? error.message : error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all data from test tables (but preserve schema)
|
||||
* This runs between each test to ensure isolation
|
||||
*/
|
||||
export async function cleanupTestData(): Promise<void> {
|
||||
// Order matters: delete child tables before parent tables
|
||||
const tables = [
|
||||
'blocks_audits',
|
||||
'blocks_summaries',
|
||||
'blocks_prices',
|
||||
'blocks_templates',
|
||||
'cpfp_clusters',
|
||||
'blocks', // blocks references pools
|
||||
'difficulty_adjustments',
|
||||
'hashrates',
|
||||
'prices',
|
||||
'node_records',
|
||||
'nodes_sockets',
|
||||
'nodes',
|
||||
'lightning_stats',
|
||||
'transactions',
|
||||
'elements_pegs',
|
||||
'federation_txos',
|
||||
'pools'
|
||||
];
|
||||
|
||||
try {
|
||||
// Disable foreign key checks temporarily for faster cleanup
|
||||
await DB.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
// Use 'silent' error logging to avoid noise for optional tables that don't exist
|
||||
await DB.query(`TRUNCATE TABLE ${table}`, [], 'silent');
|
||||
} catch (e) {
|
||||
// Table might not exist, that's okay for optional tables
|
||||
// Silently ignore - no need to log since these are expected for optional features
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enable foreign key checks
|
||||
await DB.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
} catch (error) {
|
||||
// Try to re-enable foreign keys even if cleanup failed
|
||||
try {
|
||||
await DB.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
logger.err('Failed to cleanup test data: ' + (error instanceof Error ? error.message : error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for database to be ready
|
||||
*/
|
||||
export async function waitForDatabase(maxRetries = 30, retryInterval = 1000): Promise<void> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await DB.query('SELECT 1');
|
||||
logger.info('Database is ready');
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.debug(`Waiting for database... attempt ${i + 1}/${maxRetries}`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryInterval));
|
||||
}
|
||||
}
|
||||
throw new Error('Database did not become ready in time');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database configuration for tests
|
||||
*/
|
||||
export function getTestDatabaseConfig() {
|
||||
return {
|
||||
host: config.DATABASE.HOST,
|
||||
port: config.DATABASE.PORT,
|
||||
database: config.DATABASE.DATABASE,
|
||||
username: config.DATABASE.USERNAME,
|
||||
enabled: config.DATABASE.ENABLED
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a test pool into the database
|
||||
*/
|
||||
export async function insertTestPool(poolData: {
|
||||
id?: number;
|
||||
name: string;
|
||||
link?: string;
|
||||
slug: string;
|
||||
addresses?: string;
|
||||
regexes?: string;
|
||||
}) {
|
||||
const [result] = await DB.query<any>(
|
||||
`INSERT INTO pools (unique_id, name, link, slug, addresses, regexes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
poolData.id || -1,
|
||||
poolData.name,
|
||||
poolData.link || '',
|
||||
poolData.slug,
|
||||
poolData.addresses || '[]',
|
||||
poolData.regexes || '[]'
|
||||
]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a test block into the database
|
||||
*/
|
||||
export async function insertTestBlock(blockData: {
|
||||
height: number;
|
||||
hash: string;
|
||||
blockTimestamp?: Date;
|
||||
size?: number;
|
||||
weight?: number;
|
||||
tx_count?: number;
|
||||
difficulty?: number;
|
||||
poolId?: number | null;
|
||||
}) {
|
||||
const timestamp = blockData.blockTimestamp || new Date();
|
||||
const size = blockData.size || 1000000;
|
||||
const weight = blockData.weight || 4000000;
|
||||
const txCount = blockData.tx_count || 2000;
|
||||
|
||||
await DB.query(
|
||||
`INSERT INTO blocks (
|
||||
height, hash, blockTimestamp, size, weight, tx_count,
|
||||
difficulty, pool_id, version, bits, nonce, merkle_root,
|
||||
previous_block_hash, median_timestamp, stale,
|
||||
fees, fee_span, median_fee,
|
||||
avg_tx_size, total_inputs, total_outputs, total_output_amt,
|
||||
segwit_total_txs, segwit_total_size, segwit_total_weight,
|
||||
header, utxoset_change
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
blockData.height,
|
||||
blockData.hash,
|
||||
timestamp,
|
||||
size,
|
||||
weight,
|
||||
txCount,
|
||||
blockData.difficulty || 1.0,
|
||||
blockData.poolId !== undefined ? blockData.poolId : null,
|
||||
0x20000000,
|
||||
0x1d00ffff,
|
||||
0,
|
||||
'0000000000000000000000000000000000000000000000000000000000000000',
|
||||
'0000000000000000000000000000000000000000000000000000000000000000',
|
||||
timestamp,
|
||||
0, // stale = false
|
||||
// Required fields with defaults
|
||||
50000000, // fees (in sats)
|
||||
JSON.stringify([0, 0, 0, 0, 0, 0, 0]), // fee_span (JSON array)
|
||||
10000, // median_fee (in sats)
|
||||
size / txCount, // avg_tx_size
|
||||
txCount * 2, // total_inputs (estimate)
|
||||
txCount * 2, // total_outputs (estimate)
|
||||
2100000000000000, // total_output_amt (21M BTC in sats, estimate)
|
||||
txCount, // segwit_total_txs (assume all segwit for test)
|
||||
size, // segwit_total_size
|
||||
weight, // segwit_total_weight
|
||||
'00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', // header (160 chars)
|
||||
0 // utxoset_change
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,52 +1,55 @@
|
|||
[
|
||||
{
|
||||
"txid": "50136231cb7eeeffb17fc41d1cca213426abe5bf3760e3d6421cad0c0edad367",
|
||||
"version": 1,
|
||||
"locktime": 0,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "c7f86fb7b830124057475b282809f3474ef3565daa3de0b599980fb9e84ab019",
|
||||
"vout": 4217,
|
||||
"prevout": {
|
||||
"scriptpubkey": "001466197b5eadd8067ec194a457e1044b6d1fbdd3b3",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 66197b5eadd8067ec194a457e1044b6d1fbdd3b3",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1qvcvhkh4dmqr8asv553t7zpztd50mm5ang4na33",
|
||||
"value": 106
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"3043021f2af6060a142c6cfd7428adad6a50745d2424813d7ced5c0bbcca85e70de1be022021440ca1c8c3ed49ecd1b64dca6911adcd430c5d3dd60d77ffe0072953999f5b01",
|
||||
"02ead5c34e3d2c506574b562f857576e11380b6ba15d9f0ad7b7303fdaa9c1513d"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967295
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"scriptpubkey": "6a023a29",
|
||||
"scriptpubkey_asm": "OP_RETURN OP_PUSHBYTES_2 3a29",
|
||||
"scriptpubkey_type": "op_return",
|
||||
"value": 0
|
||||
{
|
||||
"txid": "f859a4e6692c3f15a279449eac191ecb9d17d2d6c003bbae9a55e5da07147eab",
|
||||
"version": 1,
|
||||
"locktime": 0,
|
||||
"size": 170,
|
||||
"weight": 371,
|
||||
"fee": 11586,
|
||||
"vin": [
|
||||
{
|
||||
"is_coinbase": false,
|
||||
"prevout": {
|
||||
"value": 11586,
|
||||
"scriptpubkey": "512088939b42f2ed113dd4756055b179cd784c2cfa52cad29046411c28335aa2055c",
|
||||
"scriptpubkey_address": "bc1p3zfekshja5gnm4r4vp2mz7wd0pxze7jjetffq3jprs5rxk4zq4wqesykl2",
|
||||
"scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 88939b42f2ed113dd4756055b179cd784c2cfa52cad29046411c28335aa2055c",
|
||||
"scriptpubkey_type": "v1_p2tr"
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"sequence": 4294967295,
|
||||
"txid": "9b93dab94a3334b6119f75d107da6bcef9435fa52ec6c0de14aa8a094794551a",
|
||||
"vout": 0,
|
||||
"witness": [
|
||||
"6840b6fa27a00ba001cc92797ce4f3ab7b7a32c21d1fce49e893b42e506bd92e8db187966a84ef799915cf671334cc59779915b192bfb66b2afcf384bb61d0f4",
|
||||
"500049276d20616e20616e6e6578212041726520796f7520616e20616e6e65783f00"
|
||||
],
|
||||
"inner_redeemscript_asm": "",
|
||||
"inner_witnessscript_asm": ""
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"value": 0,
|
||||
"scriptpubkey": "6a05616e6e6578",
|
||||
"scriptpubkey_address": "",
|
||||
"scriptpubkey_asm": "OP_RETURN OP_PUSHBYTES_5 616e6e6578",
|
||||
"scriptpubkey_type": "op_return"
|
||||
}
|
||||
],
|
||||
"status": {
|
||||
"confirmed": true,
|
||||
"block_height": 896088,
|
||||
"block_hash": "0000000000000000000148370c6ad0eceb23d0de54ea86a362679cee7fcd3f4a",
|
||||
"block_time": 1746868490
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "6a036d7648",
|
||||
"scriptpubkey_asm": "OP_RETURN OP_PUSHBYTES_3 6d7648",
|
||||
"scriptpubkey_type": "op_return",
|
||||
"value": 0
|
||||
}
|
||||
],
|
||||
"size": 186,
|
||||
"weight": 420,
|
||||
"sigops": 1,
|
||||
"fee": 106,
|
||||
"status": {
|
||||
"confirmed": true,
|
||||
"block_height": 836361,
|
||||
"block_hash": "0000000000000000000341cc26cda4af82cd25f7063c448772228cbf2836915b",
|
||||
"block_time": 1711448028
|
||||
"order": 2877166599,
|
||||
"vsize": 93,
|
||||
"adjustedVsize": 92.75,
|
||||
"sigops": 0,
|
||||
"feePerVsize": 124.91644204851752,
|
||||
"adjustedFeePerVsize": 124.91644204851752,
|
||||
"effectiveFeePerVsize": 124.91644204851752
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -51,7 +51,7 @@ describe('Mempool Backend Config', () => {
|
|||
MAX_PUSH_TX_SIZE_WEIGHT: 400000,
|
||||
ALLOW_UNREACHABLE: true,
|
||||
PRICE_UPDATES_PER_HOUR: 1,
|
||||
MAX_TRACKED_ADDRESSES: 1,
|
||||
MAX_TRACKED_ADDRESSES: 1
|
||||
});
|
||||
|
||||
expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true });
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class AccelerationRoutes {
|
|||
public initRoutes(app: Application): void {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations', this.$getAcceleratorAccelerations.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/:txid', this.$getAcceleratorAcceleration.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/history', this.$getAcceleratorAccelerationsHistory.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/history/aggregated', this.$getAcceleratorAccelerationsHistoryAggregated.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'services/accelerator/accelerations/stats', this.$getAcceleratorAccelerationsStats.bind(this))
|
||||
|
|
@ -23,6 +24,19 @@ class AccelerationRoutes {
|
|||
res.status(200).send(Object.values(accelerations));
|
||||
}
|
||||
|
||||
private async $getAcceleratorAcceleration(req: Request, res: Response): Promise<void> {
|
||||
if (req.params.txid) {
|
||||
const acceleration = await AccelerationRepository.$getAccelerationInfoForTxid(req.params.txid);
|
||||
if (acceleration) {
|
||||
res.status(200).send(acceleration);
|
||||
} else {
|
||||
res.status(404).send('Acceleration not found');
|
||||
}
|
||||
} else {
|
||||
res.status(400).send('txid is required');
|
||||
}
|
||||
}
|
||||
|
||||
private async $getAcceleratorAccelerationsHistory(req: Request, res: Response): Promise<void> {
|
||||
const history = await AccelerationRepository.$getAccelerationInfo(null, req.query.blockHeight ? parseInt(req.query.blockHeight as string, 10) : null);
|
||||
res.status(200).send(history.map(accel => ({
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import path from 'path';
|
|||
import os from 'os';
|
||||
import { IBackendInfo } from '../mempool.interfaces';
|
||||
import config from '../config';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
import logger from '../logger';
|
||||
|
||||
class BackendInfo {
|
||||
private backendInfo: IBackendInfo;
|
||||
private timer;
|
||||
|
||||
constructor() {
|
||||
// This file is created by ./fetch-version.ts during building
|
||||
|
|
@ -26,7 +29,22 @@ class BackendInfo {
|
|||
gitCommit: versionInfo.gitCommit,
|
||||
lightning: config.LIGHTNING.ENABLED,
|
||||
backend: config.MEMPOOL.BACKEND,
|
||||
coreVersion: '?',
|
||||
};
|
||||
|
||||
this.timer = setInterval(async () => {
|
||||
await this.$updateCoreVersion();
|
||||
}, 10 * 60 * 1000); // every 10 minutes
|
||||
this.$updateCoreVersion(); // starting immediately
|
||||
}
|
||||
|
||||
private async $updateCoreVersion(): Promise<void> {
|
||||
try {
|
||||
const networkInfo = await bitcoinClient.getNetworkInfo();
|
||||
this.backendInfo.coreVersion = networkInfo.subversion;
|
||||
} catch (e) {
|
||||
logger.err(`Exception in $updateCoreVersion. Reason: ${(e instanceof Error ? e.message : e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
public getBackendInfo(): IBackendInfo {
|
||||
|
|
|
|||
|
|
@ -11,17 +11,19 @@ export interface AbstractBitcoinApi {
|
|||
$getTransactionMerkleProof(txId: string): Promise<IEsploraApi.MerkleProof>;
|
||||
$getBlockHeightTip(): Promise<number>;
|
||||
$getBlockHashTip(): Promise<string>;
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]>;
|
||||
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getTxIdsForBlock(hash: string, fallbackToCore?: boolean): Promise<string[]>;
|
||||
$getTxsForBlock(hash: string, fallbackToCore?: boolean): Promise<IEsploraApi.Transaction[]>;
|
||||
$getBlockHash(height: number): Promise<string>;
|
||||
$getBlockHeader(hash: string): Promise<string>;
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block>;
|
||||
$getRawBlock(hash: string): Promise<Buffer>;
|
||||
$getAddress(address: string): Promise<IEsploraApi.Address>;
|
||||
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getAddressUtxos(address: string): Promise<IEsploraApi.UTXO[]>;
|
||||
$getAddressPrefix(prefix: string): string[];
|
||||
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash>;
|
||||
$getScriptHashTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getScriptHashUtxos(scripthash: string): Promise<IEsploraApi.UTXO[]>;
|
||||
$sendRawTransaction(rawTransaction: string): Promise<string>;
|
||||
$testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise<TestMempoolAcceptResult[]>;
|
||||
$submitPackage(rawTransactions: string[], maxfeerate?: number, maxburnamount?: number): Promise<SubmitPackageResult>;
|
||||
|
|
|
|||
|
|
@ -107,16 +107,22 @@ class BitcoinApi implements AbstractBitcoinApi {
|
|||
return this.bitcoindClient.getBestBlockHash();
|
||||
}
|
||||
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||
$getTxIdsForBlock(hash: string, fallbackToCore = false): Promise<string[]> {
|
||||
return this.bitcoindClient.getBlock(hash, 1)
|
||||
.then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx);
|
||||
}
|
||||
|
||||
async $getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> {
|
||||
async $getTxsForBlock(hash: string, fallbackToCore = false): Promise<IEsploraApi.Transaction[]> {
|
||||
const verboseBlock: IBitcoinApi.VerboseBlock = await this.bitcoindClient.getBlock(hash, 2);
|
||||
const transactions: IEsploraApi.Transaction[] = [];
|
||||
for (const tx of verboseBlock.tx) {
|
||||
const converted = await this.$convertTransaction(tx, true);
|
||||
const converted = await this.$convertTransaction(tx, true, false, verboseBlock.confirmations === -1);
|
||||
converted.status = {
|
||||
confirmed: true,
|
||||
block_height: verboseBlock.height,
|
||||
block_hash: hash,
|
||||
block_time: verboseBlock.time,
|
||||
};
|
||||
transactions.push(converted);
|
||||
}
|
||||
return transactions;
|
||||
|
|
@ -153,6 +159,10 @@ class BitcoinApi implements AbstractBitcoinApi {
|
|||
throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getAddressUtxos(address: string): Promise<IEsploraApi.UTXO[]> {
|
||||
throw new Error('Method getAddressUtxos not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
|
||||
throw new Error('Method getScriptHash not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
|
@ -161,6 +171,10 @@ class BitcoinApi implements AbstractBitcoinApi {
|
|||
throw new Error('Method getScriptHashTransactions not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getScriptHashUtxos(scripthash: string): Promise<IEsploraApi.UTXO[]> {
|
||||
throw new Error('Method getScriptHashUtxos not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
|
||||
return this.bitcoindClient.getRawMemPool();
|
||||
}
|
||||
|
|
@ -269,7 +283,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
|||
return this.bitcoindClient.getNetworkHashPs(120, blockHeight);
|
||||
}
|
||||
|
||||
protected async $convertTransaction(transaction: IBitcoinApi.Transaction, addPrevout: boolean, lazyPrevouts = false): Promise<IEsploraApi.Transaction> {
|
||||
protected async $convertTransaction(transaction: IBitcoinApi.Transaction, addPrevout: boolean, lazyPrevouts = false, allowMissingPrevouts = false): Promise<IEsploraApi.Transaction> {
|
||||
let esploraTransaction: IEsploraApi.Transaction = {
|
||||
txid: transaction.txid,
|
||||
version: transaction.version,
|
||||
|
|
@ -318,7 +332,13 @@ class BitcoinApi implements AbstractBitcoinApi {
|
|||
}
|
||||
|
||||
if (addPrevout) {
|
||||
esploraTransaction = await this.$calculateFeeFromInputs(esploraTransaction, false, lazyPrevouts);
|
||||
try {
|
||||
esploraTransaction = await this.$calculateFeeFromInputs(esploraTransaction, false, lazyPrevouts);
|
||||
} catch (e) {
|
||||
if (!allowMissingPrevouts) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else if (!transaction.confirmations) {
|
||||
esploraTransaction = await this.$appendMempoolFeeData(esploraTransaction);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import rbfCache from '../rbf-cache';
|
|||
import { calculateMempoolTxCpfp } from '../cpfp';
|
||||
import { handleError } from '../../utils/api';
|
||||
import poolsUpdater from '../../tasks/pools-updater';
|
||||
import chainTips from '../chain-tips';
|
||||
|
||||
const TXID_REGEX = /^[a-f0-9]{64}$/i;
|
||||
const BLOCK_HASH_REGEX = /^[a-f0-9]{64}$/i;
|
||||
|
|
@ -35,6 +36,7 @@ class BitcoinRoutes {
|
|||
.get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.$getCpfpInfo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', this.getDifficultyChange)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', this.getRecommendedFees)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/precise', this.getPreciseRecommendedFees)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/mempool-blocks', this.getMempoolBlocks)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', this.getBackendInfo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData)
|
||||
|
|
@ -55,6 +57,8 @@ class BitcoinRoutes {
|
|||
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'chain-tips', this.getChainTips.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'stale-tips', this.getStaleTips.bind(this))
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'prevouts', this.$getPrevouts)
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'cpfp', this.getCpfpLocalTxs)
|
||||
// Temporarily add txs/package endpoint for all backends until esplora supports it
|
||||
|
|
@ -88,9 +92,11 @@ class BitcoinRoutes {
|
|||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs/summary', this.getAddressTransactionSummary)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/utxo', this.getAddressUtxo)
|
||||
.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 + 'scripthash/:scripthash/txs/summary', this.getScriptHashTransactionSummary)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash/utxo', this.getScriptHashUtxo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', this.getAddressPrefix)
|
||||
;
|
||||
}
|
||||
|
|
@ -117,6 +123,16 @@ class BitcoinRoutes {
|
|||
res.json(result);
|
||||
}
|
||||
|
||||
private getPreciseRecommendedFees(req: Request, res: Response) {
|
||||
if (!mempool.isInSync()) {
|
||||
res.statusCode = 503;
|
||||
res.send('Service Unavailable');
|
||||
return;
|
||||
}
|
||||
const result = feeApi.getPreciseRecommendedFee();
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
private getMempoolBlocks(req: Request, res: Response) {
|
||||
try {
|
||||
const result = mempoolBlocks.getMempoolBlocks();
|
||||
|
|
@ -470,7 +486,7 @@ class BitcoinRoutes {
|
|||
|
||||
private async getBlocks(req: Request, res: Response) {
|
||||
try {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
||||
const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(await blocks.$getBlocks(height, 15));
|
||||
|
|
@ -484,7 +500,7 @@ class BitcoinRoutes {
|
|||
|
||||
private async getBlocksByBulk(req: Request, res: Response) {
|
||||
try {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { // Liquid - Not implemented
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK) === false) { // Liquid - Not implemented
|
||||
handleError(req, res, 404, `This API is only available for Bitcoin networks`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -524,6 +540,46 @@ class BitcoinRoutes {
|
|||
}
|
||||
}
|
||||
|
||||
private async getChainTips(req: Request, res: Response) {
|
||||
try {
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
const tips = await chainTips.getChainTips();
|
||||
if (tips.length > 0) {
|
||||
res.json(tips);
|
||||
} else {
|
||||
handleError(req, res, 503, `Temporarily unavailable`);
|
||||
return;
|
||||
}
|
||||
} else { // Liquid
|
||||
handleError(req, res, 404, `This API is only available for Bitcoin networks`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(req, res, 500, 'Failed to get chain tips');
|
||||
}
|
||||
}
|
||||
|
||||
private async getStaleTips(req: Request, res: Response) {
|
||||
try {
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
const tips = await chainTips.getStaleTips();
|
||||
if (tips.length > 0) {
|
||||
res.json(tips);
|
||||
} else {
|
||||
handleError(req, res, 503, `Temporarily unavailable`);
|
||||
return;
|
||||
}
|
||||
} else { // Liquid
|
||||
handleError(req, res, 404, `This API is only available for Bitcoin networks`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(req, res, 500, 'Failed to get stale tips');
|
||||
}
|
||||
}
|
||||
|
||||
private async getLegacyBlocks(req: Request, res: Response) {
|
||||
try {
|
||||
const returnBlocks: IEsploraApi.Block[] = [];
|
||||
|
|
@ -645,6 +701,28 @@ class BitcoinRoutes {
|
|||
}
|
||||
}
|
||||
|
||||
private async getAddressUtxo(req: Request, res: Response): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND === 'none') {
|
||||
handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
|
||||
return;
|
||||
}
|
||||
if (!ADDRESS_REGEX.test(req.params.address)) {
|
||||
handleError(req, res, 501, `Invalid address`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const addressData = await bitcoinApi.$getAddressUtxos(req.params.address);
|
||||
res.json(addressData);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
|
||||
handleError(req, res, 413, e.message);
|
||||
return;
|
||||
}
|
||||
handleError(req, res, 500, 'Failed to get address');
|
||||
}
|
||||
}
|
||||
|
||||
private async getAddressTransactionSummary(req: Request, res: Response): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
handleError(req, res, 405, 'Address summary lookups require mempool/electrs backend.');
|
||||
|
|
@ -704,6 +782,30 @@ class BitcoinRoutes {
|
|||
}
|
||||
}
|
||||
|
||||
private async getScriptHashUtxo(req: Request, res: Response): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND === 'none') {
|
||||
handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
|
||||
return;
|
||||
}
|
||||
if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) {
|
||||
handleError(req, res, 501, `Invalid scripthash`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// electrum expects scripthashes in little-endian
|
||||
const electrumScripthash = req.params.scripthash.match(/../g)?.reverse().join('') ?? '';
|
||||
const addressData = await bitcoinApi.$getScriptHashUtxos(electrumScripthash);
|
||||
res.json(addressData);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
|
||||
handleError(req, res, 413, e.message);
|
||||
return;
|
||||
}
|
||||
handleError(req, res, 500, 'Failed to get script hash');
|
||||
}
|
||||
}
|
||||
|
||||
private async getScriptHashTransactionSummary(req: Request, res: Response): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
handleError(req, res, 405, 'Scripthash summary lookups require mempool/electrs backend.');
|
||||
|
|
|
|||
|
|
@ -9,4 +9,11 @@ export namespace IElectrumApi {
|
|||
tx_hash: string;
|
||||
fee?: number;
|
||||
}
|
||||
|
||||
export interface ScriptHashUtxos {
|
||||
tx_pos: number;
|
||||
value: number;
|
||||
tx_hash: string;
|
||||
height: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,15 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
|||
}
|
||||
}
|
||||
|
||||
async $getAddressUtxos(address: string): Promise<IEsploraApi.UTXO[]> {
|
||||
const addressInfo = await this.bitcoindClient.validateAddress(address);
|
||||
if (!addressInfo || !addressInfo.isvalid) {
|
||||
return [];
|
||||
}
|
||||
const scripthash = this.encodeScriptHash(addressInfo.scriptPubKey);
|
||||
return this.$getScriptHashUtxos(scripthash);
|
||||
}
|
||||
|
||||
async $getScriptHashTransactions(scripthash: string, lastSeenTxId?: string): Promise<IEsploraApi.Transaction[]> {
|
||||
try {
|
||||
loadingIndicators.setProgress('address-' + scripthash, 0);
|
||||
|
|
@ -197,6 +206,44 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
|||
}
|
||||
}
|
||||
|
||||
async $getScriptHashUtxos(scripthash: string): Promise<IEsploraApi.UTXO[]> {
|
||||
const utxos = await this.$getScriptHashUnspent(scripthash);
|
||||
const result: IEsploraApi.UTXO[] = [];
|
||||
for(let utxo of utxos) {
|
||||
if(utxo.height===0) {
|
||||
//Unconfirmed
|
||||
result.push({
|
||||
txid: utxo.tx_hash,
|
||||
vout: utxo.tx_pos,
|
||||
status: {
|
||||
confirmed: false
|
||||
},
|
||||
value: utxo.value
|
||||
});
|
||||
} else {
|
||||
//Confirmed
|
||||
const blockHash = await this.$getBlockHash(utxo.height);
|
||||
const block = await this.$getBlock(blockHash);
|
||||
result.push({
|
||||
txid: utxo.tx_hash,
|
||||
vout: utxo.tx_pos,
|
||||
status: {
|
||||
confirmed: true,
|
||||
block_height: utxo.height,
|
||||
block_hash: blockHash,
|
||||
block_time: block.timestamp
|
||||
},
|
||||
value: utxo.value
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private $getScriptHashUnspent(scriptHash: string): Promise<IElectrumApi.ScriptHashUtxos[]> {
|
||||
return this.electrumClient.blockchainScripthash_listunspent(scriptHash);
|
||||
}
|
||||
|
||||
async $getTransactionMerkleProof(txId: string): Promise<IEsploraApi.MerkleProof> {
|
||||
const tx = await this.$getRawTransaction(txId);
|
||||
return this.electrumClient.blockchainTransaction_getMerkle(txId, tx.status.block_height);
|
||||
|
|
|
|||
|
|
@ -192,4 +192,16 @@ export namespace IEsploraApi {
|
|||
block_height: number;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export interface UTXO {
|
||||
txid: string;
|
||||
vout: number;
|
||||
status: {
|
||||
confirmed: boolean;
|
||||
block_height?: number;
|
||||
block_hash?: string;
|
||||
block_time?: number;
|
||||
},
|
||||
value: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import logger from '../../logger';
|
|||
import { Common } from '../common';
|
||||
import { SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface';
|
||||
import os from 'os';
|
||||
import { bitcoinCoreApi } from './bitcoin-api-factory';
|
||||
interface FailoverHost {
|
||||
host: string,
|
||||
rtts: number[],
|
||||
|
|
@ -27,6 +28,8 @@ interface FailoverHost {
|
|||
hybrid?: string,
|
||||
backend?: string,
|
||||
electrs?: string,
|
||||
ssr?: string,
|
||||
core?: string,
|
||||
lastUpdated: number,
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +37,7 @@ interface FailoverHost {
|
|||
class FailoverRouter {
|
||||
activeHost: FailoverHost;
|
||||
fallbackHost: FailoverHost;
|
||||
maxSlippage: number = config.ESPLORA.MAX_BEHIND_TIP ?? 2;
|
||||
maxSlippage: number = config.ESPLORA.MAX_BEHIND_TIP ?? (Common.isLiquid() ? 8 : 2);
|
||||
maxHeight: number = 0;
|
||||
hosts: FailoverHost[];
|
||||
multihost: boolean;
|
||||
|
|
@ -144,7 +147,8 @@ class FailoverRouter {
|
|||
if (Date.now() - host.hashes.lastUpdated > this.gitHashInterval) {
|
||||
await Promise.all([
|
||||
this.$updateFrontendGitHash(host),
|
||||
this.$updateBackendGitHash(host),
|
||||
this.$updateBackendVersions(host),
|
||||
this.$updateSSRGitHash(host),
|
||||
config.MEMPOOL.OFFICIAL ? this.$updateHybridGitHash(host) : Promise.resolve(),
|
||||
]);
|
||||
host.hashes.lastUpdated = Date.now();
|
||||
|
|
@ -300,18 +304,33 @@ class FailoverRouter {
|
|||
}
|
||||
}
|
||||
|
||||
private async $updateBackendGitHash(host: FailoverHost): Promise<void> {
|
||||
private async $updateBackendVersions(host: FailoverHost): Promise<void> {
|
||||
try {
|
||||
const url = `${host.publicDomain}/api/v1/backend-info`;
|
||||
const response = await this.pollConnection.get<any>(url, { timeout: config.ESPLORA.FALLBACK_TIMEOUT });
|
||||
if (response.data?.gitCommit) {
|
||||
host.hashes.backend = response.data.gitCommit;
|
||||
}
|
||||
if (response.data?.coreVersion) {
|
||||
host.hashes.core = response.data.coreVersion;
|
||||
}
|
||||
} catch (e) {
|
||||
// failed to get backend build hash - do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async $updateSSRGitHash(host: FailoverHost): Promise<void> {
|
||||
try {
|
||||
const url = `${host.publicDomain}/ssr/api/status`;
|
||||
const response = await this.pollConnection.get<any>(url, { timeout: config.ESPLORA.FALLBACK_TIMEOUT });
|
||||
if (response.data?.gitHash) {
|
||||
host.hashes.ssr = response.data.gitHash;
|
||||
}
|
||||
} catch (e) {
|
||||
// failed to get ssr build hash - do nothing
|
||||
}
|
||||
}
|
||||
|
||||
// returns the public mempool domain corresponding to an esplora server url
|
||||
// (a bit of a hack to avoid manually specifying frontend & backend URLs for each esplora server)
|
||||
private extractPublicDomain(url: string): string {
|
||||
|
|
@ -408,12 +427,36 @@ class ElectrsApi implements AbstractBitcoinApi {
|
|||
return this.failoverRouter.$get<string>('/blocks/tip/hash');
|
||||
}
|
||||
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||
return this.failoverRouter.$get<string[]>('/block/' + hash + '/txids');
|
||||
async $getTxIdsForBlock(hash: string, fallbackToCore = false): Promise<string[]> {
|
||||
try {
|
||||
const txids = await this.failoverRouter.$get<string[]>('/block/' + hash + '/txids');
|
||||
return txids;
|
||||
} catch (e) {
|
||||
if (fallbackToCore && isAxiosError(e) && e.response?.status === 404) {
|
||||
// might be a stale block, see if Core has it?
|
||||
const coreBlock = await bitcoinCoreApi.$getBlock(hash);
|
||||
if (coreBlock?.stale) {
|
||||
return bitcoinCoreApi.$getTxIdsForBlock(hash);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> {
|
||||
return this.failoverRouter.$get<IEsploraApi.Transaction[]>('/internal/block/' + hash + '/txs');
|
||||
async $getTxsForBlock(hash: string, fallbackToCore = false): Promise<IEsploraApi.Transaction[]> {
|
||||
try {
|
||||
const txs = await this.failoverRouter.$get<IEsploraApi.Transaction[]>('/internal/block/' + hash + '/txs');
|
||||
return txs;
|
||||
} catch (e) {
|
||||
if (fallbackToCore && isAxiosError(e) && e.response?.status === 404) {
|
||||
// might be a stale block, see if Core has it?
|
||||
const coreBlock = await bitcoinCoreApi.$getBlock(hash);
|
||||
if (coreBlock?.stale) {
|
||||
return bitcoinCoreApi.$getTxsForBlock(hash);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
$getBlockHash(height: number): Promise<string> {
|
||||
|
|
@ -441,6 +484,10 @@ class ElectrsApi implements AbstractBitcoinApi {
|
|||
throw new Error('Method getAddressTransactions not implemented.');
|
||||
}
|
||||
|
||||
$getAddressUtxos(address: string): Promise<IEsploraApi.UTXO[]> {
|
||||
return this.failoverRouter.$get<IEsploraApi.UTXO[]>('/address/' + address + '/utxo');
|
||||
}
|
||||
|
||||
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
|
||||
throw new Error('Method getScriptHash not implemented.');
|
||||
}
|
||||
|
|
@ -449,6 +496,10 @@ class ElectrsApi implements AbstractBitcoinApi {
|
|||
throw new Error('Method getScriptHashTransactions not implemented.');
|
||||
}
|
||||
|
||||
$getScriptHashUtxos(scripthash: string): Promise<IEsploraApi.UTXO[]> {
|
||||
throw new Error('Method getScriptHashUtxos not implemented.');
|
||||
}
|
||||
|
||||
$getAddressPrefix(prefix: string): string[] {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,17 +94,19 @@ class Blocks {
|
|||
txIds: string[] | null = null,
|
||||
quiet: boolean = false,
|
||||
addMempoolData: boolean = false,
|
||||
stale: boolean = false,
|
||||
): Promise<TransactionExtended[]> {
|
||||
const isEsplora = config.MEMPOOL.BACKEND === 'esplora';
|
||||
const transactionMap: { [txid: string]: TransactionExtended } = {};
|
||||
|
||||
if (!txIds) {
|
||||
txIds = await bitcoinApi.$getTxIdsForBlock(blockHash);
|
||||
txIds = await bitcoinApi.$getTxIdsForBlock(blockHash, stale);
|
||||
}
|
||||
|
||||
const mempool = memPool.getMempool();
|
||||
let foundInMempool = 0;
|
||||
let totalFound = 0;
|
||||
let missing = 0;
|
||||
|
||||
// Copy existing transactions from the mempool
|
||||
if (!onlyCoinbase) {
|
||||
|
|
@ -136,14 +138,17 @@ class Blocks {
|
|||
} catch (e) {
|
||||
const msg = `Cannot fetch coinbase tx ${txIds[0]}. Reason: ` + (e instanceof Error ? e.message : e);
|
||||
logger.err(msg);
|
||||
throw new Error(msg);
|
||||
// tolerate this error for stale blocks (the cb transaction won't be accessible via normal RPCs)
|
||||
if (!stale) {
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch remaining txs in bulk
|
||||
if (isEsplora && (txIds.length - totalFound > 500)) {
|
||||
if ((isEsplora && (txIds.length - totalFound > 500)) || stale) {
|
||||
try {
|
||||
const rawTransactions = await bitcoinApi.$getTxsForBlock(blockHash);
|
||||
const rawTransactions = await bitcoinApi.$getTxsForBlock(blockHash, stale);
|
||||
for (const tx of rawTransactions) {
|
||||
if (!transactionMap[tx.txid]) {
|
||||
transactionMap[tx.txid] = addMempoolData ? transactionUtils.extendMempoolTransaction(tx) : transactionUtils.extendTransaction(tx);
|
||||
|
|
@ -184,7 +189,6 @@ class Blocks {
|
|||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -267,7 +271,7 @@ class Blocks {
|
|||
extras.segwitTotalSize = 0;
|
||||
extras.segwitTotalWeight = 0;
|
||||
} else {
|
||||
const stats: IBitcoinApi.BlockStats = await bitcoinClient.getBlockStats(block.id);
|
||||
const stats: IBitcoinApi.BlockStats = await this.$getBlockStats(block, transactions);
|
||||
let feeStats = {
|
||||
medianFee: stats.feerate_percentiles[2], // 50th percentiles
|
||||
feeRange: [stats.minfeerate, stats.feerate_percentiles, stats.maxfeerate].flat(),
|
||||
|
|
@ -323,7 +327,7 @@ class Blocks {
|
|||
extras.totalInputAmt = null;
|
||||
}
|
||||
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
let pool: PoolTag;
|
||||
if (coinbaseTx !== undefined) {
|
||||
pool = await this.$findBlockMiner(coinbaseTx);
|
||||
|
|
@ -368,6 +372,79 @@ class Blocks {
|
|||
return <BlockExtended>blk;
|
||||
}
|
||||
|
||||
private async $getBlockStats(block: IEsploraApi.Block, transactions: TransactionExtended[]): Promise<IBitcoinApi.BlockStats> {
|
||||
if (!block.stale) {
|
||||
return bitcoinClient.getBlockStats(block.id);
|
||||
}
|
||||
|
||||
// TODO: make these match the definitions used by the RPC response
|
||||
const totalFee = transactions.reduce((acc, tx) => acc + tx.fee, 0);
|
||||
const totalVsize = transactions.reduce((acc, tx) => acc + tx.vsize, 0);
|
||||
const totalReward = transactions[0].vout.reduce((acc, vout) => acc + vout.value, 0);
|
||||
const sortedByFee = transactions.sort((a, b) => a.fee - b.fee);
|
||||
const sortedByVsize = transactions.sort((a, b) => a.vsize - b.vsize);
|
||||
const sortedByFeerate = transactions.sort((a, b) => (a.fee / a.weight) - (b.fee / b.weight));
|
||||
const sortedFeerates = sortedByFeerate.map(tx => (tx.fee / (tx.weight / 4)));
|
||||
const avgfee = totalFee / transactions.length;
|
||||
const avgfeerate = totalFee / (block.weight / 4);
|
||||
const avgtxsize = totalVsize / transactions.length;
|
||||
const medianfee = sortedByFee[Math.floor(transactions.length / 2)].fee;
|
||||
const mediantime = block.timestamp;
|
||||
const mediantxsize = sortedByVsize[Math.floor(transactions.length / 2)].vsize;
|
||||
const minfee = sortedByFee[0].fee;
|
||||
const maxfee = sortedByFee[sortedByFee.length - 1].fee;
|
||||
const minfeerate = sortedFeerates[0];
|
||||
const maxfeerate = sortedFeerates[sortedFeerates.length - 1];
|
||||
const mintxsize = sortedByVsize[0].vsize;
|
||||
const maxtxsize = sortedByVsize[sortedByVsize.length - 1].vsize;
|
||||
const ins = transactions.reduce((acc, tx) => acc + tx.vin.length, 0);
|
||||
const outs = transactions.reduce((acc, tx) => acc + tx.vout.length, 0);
|
||||
const subsidy = totalReward - totalFee;
|
||||
const swtotal_size = 0;
|
||||
const swtotal_weight = 0;
|
||||
const swtxs = 0;
|
||||
const time = block.timestamp;
|
||||
const total_out = transactions.reduce((acc, tx) => acc + tx.vout.reduce((acc, vout) => acc + vout.value, 0), 0);
|
||||
const total_size = block.size;
|
||||
const total_weight = block.weight;
|
||||
const totalfee = totalFee;
|
||||
const txs = transactions.length;
|
||||
const utxo_increase = 0;
|
||||
const utxo_size_inc = 0;
|
||||
|
||||
return {
|
||||
avgfee,
|
||||
avgfeerate,
|
||||
avgtxsize,
|
||||
blockhash: block.id,
|
||||
feerate_percentiles: [minfeerate, sortedFeerates[Math.floor(transactions.length / 4)], medianfee, sortedFeerates[Math.floor(transactions.length * 3 / 4)], maxfeerate],
|
||||
height: block.height,
|
||||
ins,
|
||||
maxfee,
|
||||
maxfeerate,
|
||||
maxtxsize,
|
||||
medianfee,
|
||||
mediantime,
|
||||
mediantxsize,
|
||||
minfee,
|
||||
minfeerate,
|
||||
mintxsize,
|
||||
outs,
|
||||
subsidy,
|
||||
swtotal_size,
|
||||
swtotal_weight,
|
||||
swtxs,
|
||||
time,
|
||||
total_out,
|
||||
total_size,
|
||||
total_weight,
|
||||
totalfee,
|
||||
txs,
|
||||
utxo_increase,
|
||||
utxo_size_inc,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find which miner found the block
|
||||
* @param txMinerInfo
|
||||
|
|
@ -452,16 +529,7 @@ class Blocks {
|
|||
indexedThisRun = 0;
|
||||
}
|
||||
|
||||
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendMempoolTransaction(tx));
|
||||
const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs);
|
||||
if (cpfpSummary) {
|
||||
await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary
|
||||
}
|
||||
} else {
|
||||
await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary
|
||||
}
|
||||
await this.$indexBlockSummary(block.hash, block.height, block.stale);
|
||||
|
||||
// Logging
|
||||
indexedThisRun++;
|
||||
|
|
@ -479,6 +547,18 @@ class Blocks {
|
|||
}
|
||||
}
|
||||
|
||||
public async $indexBlockSummary(hash: string, height: number, stale?: boolean): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
const txs = (await bitcoinApi.$getTxsForBlock(hash, stale)).map(tx => transactionUtils.extendMempoolTransaction(tx));
|
||||
const cpfpSummary = await this.$indexCPFP(hash, height, txs, stale);
|
||||
if (cpfpSummary) {
|
||||
await this.$getStrippedBlockTransactions(hash, true, true, cpfpSummary, height); // This will index the block summary
|
||||
}
|
||||
} else {
|
||||
await this.$getStrippedBlockTransactions(hash, true, true); // This will index the block summary
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [INDEXING] Index transaction CPFP data for all blocks
|
||||
*/
|
||||
|
|
@ -582,8 +662,7 @@ class Blocks {
|
|||
return;
|
||||
}
|
||||
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
const currentBlockHeight = blockchainInfo.blocks;
|
||||
const currentBlockHeight = this.getCurrentBlockHeight();
|
||||
|
||||
const targetSummaryVersion: number = 1;
|
||||
const targetTemplateVersion: number = 1;
|
||||
|
|
@ -626,7 +705,7 @@ class Blocks {
|
|||
if (unclassifiedBlocks[height]) {
|
||||
const blockHash = unclassifiedBlocks[height];
|
||||
// fetch transactions
|
||||
txs = (await bitcoinApi.$getTxsForBlock(blockHash)).map(tx => transactionUtils.extendMempoolTransaction(tx)) || [];
|
||||
txs = (await bitcoinApi.$getTxsForBlock(blockHash, true)).map(tx => transactionUtils.extendMempoolTransaction(tx)) || [];
|
||||
// add CPFP
|
||||
const cpfpSummary = calculateGoodBlockCpfp(height, txs, []);
|
||||
// classify
|
||||
|
|
@ -804,7 +883,7 @@ class Blocks {
|
|||
}
|
||||
const blockHash = await bitcoinApi.$getBlockHash(blockHeight);
|
||||
const block: IEsploraApi.Block = await bitcoinApi.$getBlock(blockHash);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, block.timestamp, true, null, true);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, block.timestamp, !block.stale, null, true, block.stale);
|
||||
const blockExtended = await this.$getBlockExtended(block, transactions);
|
||||
|
||||
newlyIndexed++;
|
||||
|
|
@ -836,6 +915,7 @@ class Blocks {
|
|||
|
||||
let fastForwarded = false;
|
||||
let handledBlocks = 0;
|
||||
const lastBlockHeight = this.currentBlockHeight;
|
||||
const blockHeightTip = await bitcoinCoreApi.$getBlockHeightTip();
|
||||
this.updateTimerProgress(timer, 'got block height tip');
|
||||
|
||||
|
|
@ -844,16 +924,6 @@ class Blocks {
|
|||
} else {
|
||||
this.currentBlockHeight = this.blocks[this.blocks.length - 1].height;
|
||||
}
|
||||
if (this.currentBlockHeight >= 503) {
|
||||
try {
|
||||
const quarterEpochBlockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight - 503);
|
||||
const quarterEpochBlock = await bitcoinApi.$getBlock(quarterEpochBlockHash);
|
||||
this.quarterEpochBlockTime = quarterEpochBlock?.timestamp;
|
||||
} catch (e) {
|
||||
this.quarterEpochBlockTime = null;
|
||||
logger.warn('failed to update last epoch block time: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
if (blockHeightTip - this.currentBlockHeight > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 2) {
|
||||
logger.info(`${blockHeightTip - this.currentBlockHeight} blocks since tip. Fast forwarding to the ${config.MEMPOOL.INITIAL_BLOCKS_AMOUNT} recent blocks`);
|
||||
|
|
@ -892,17 +962,20 @@ class Blocks {
|
|||
}
|
||||
}
|
||||
|
||||
const heightChanged = lastBlockHeight !== this.currentBlockHeight;
|
||||
// make sure to update the quarter epoch block time now if we won't do it inside the loop
|
||||
if (this.currentBlockHeight >= blockHeightTip && (heightChanged || this.quarterEpochBlockTime == null)) {
|
||||
await this.updateQuarterEpochBlockTime();
|
||||
}
|
||||
|
||||
while (this.currentBlockHeight < blockHeightTip) {
|
||||
if (this.currentBlockHeight === 0) {
|
||||
this.currentBlockHeight = blockHeightTip;
|
||||
await this.updateQuarterEpochBlockTime();
|
||||
} else {
|
||||
this.currentBlockHeight++;
|
||||
await this.updateQuarterEpochBlockTime();
|
||||
logger.debug(`New block found (#${this.currentBlockHeight})!`);
|
||||
// skip updating the orphan block cache if we've fallen behind the chain tip
|
||||
if (this.currentBlockHeight >= blockHeightTip - 2) {
|
||||
this.updateTimerProgress(timer, `getting orphaned blocks for ${this.currentBlockHeight}`);
|
||||
await chainTips.updateOrphanedBlocks();
|
||||
}
|
||||
}
|
||||
|
||||
this.updateTimerProgress(timer, `getting block data for ${this.currentBlockHeight}`);
|
||||
|
|
@ -931,38 +1004,7 @@ class Blocks {
|
|||
|
||||
if (Common.indexingEnabled()) {
|
||||
if (!fastForwarded) {
|
||||
const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1);
|
||||
this.updateTimerProgress(timer, `got block by height for ${this.currentBlockHeight}`);
|
||||
if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock.id) {
|
||||
logger.warn(`Chain divergence detected at block ${lastBlock.height}, re-indexing most recent data`, logger.tags.mining);
|
||||
// We assume there won't be a reorg with more than 10 block depth
|
||||
this.updateTimerProgress(timer, `rolling back diverged chain from ${this.currentBlockHeight}`);
|
||||
await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10);
|
||||
await HashratesRepository.$deleteLastEntries();
|
||||
await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10);
|
||||
await AccelerationRepository.$deleteAccelerationsFrom(lastBlock.height - 10);
|
||||
this.blocks = this.blocks.slice(0, -10);
|
||||
this.updateTimerProgress(timer, `rolled back chain divergence from ${this.currentBlockHeight}`);
|
||||
for (let i = 10; i >= 0; --i) {
|
||||
const newBlock = await this.$indexBlock(lastBlock.height - i);
|
||||
this.blocks.push(newBlock);
|
||||
this.updateTimerProgress(timer, `reindexed block`);
|
||||
let newCpfpSummary;
|
||||
if (config.MEMPOOL.CPFP_INDEXING) {
|
||||
newCpfpSummary = await this.$indexCPFP(newBlock.id, lastBlock.height - i);
|
||||
this.updateTimerProgress(timer, `reindexed block cpfp`);
|
||||
}
|
||||
await this.$getStrippedBlockTransactions(newBlock.id, true, true, newCpfpSummary, newBlock.height);
|
||||
this.updateTimerProgress(timer, `reindexed block summary`);
|
||||
}
|
||||
await mining.$indexDifficultyAdjustments();
|
||||
await DifficultyAdjustmentsRepository.$deleteLastAdjustment();
|
||||
this.updateTimerProgress(timer, `reindexed difficulty adjustments`);
|
||||
logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining);
|
||||
indexer.reindex();
|
||||
|
||||
websocketHandler.handleReorg();
|
||||
}
|
||||
await this.$handleReorgs(blockExtended, timer);
|
||||
}
|
||||
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||
|
|
@ -1034,6 +1076,12 @@ class Blocks {
|
|||
this.currentBits = block.bits;
|
||||
}
|
||||
|
||||
// skip updating the orphan block cache if we've fallen behind the chain tip
|
||||
if (this.currentBlockHeight >= blockHeightTip - 2) {
|
||||
this.updateTimerProgress(timer, `getting orphaned blocks for ${this.currentBlockHeight}`);
|
||||
await chainTips.updateOrphanedBlocks();
|
||||
}
|
||||
|
||||
// wait for pending async callbacks to finish
|
||||
this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`);
|
||||
await Promise.all(callbackPromises);
|
||||
|
|
@ -1098,21 +1146,121 @@ class Blocks {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a block if it's missing from the database. Returns the block after indexing
|
||||
*/
|
||||
public async $indexBlock(height: number): Promise<BlockExtended> {
|
||||
if (Common.indexingEnabled()) {
|
||||
private async updateQuarterEpochBlockTime(): Promise<void> {
|
||||
if (this.currentBlockHeight >= 503) {
|
||||
try {
|
||||
const quarterEpochBlockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight - 503);
|
||||
const quarterEpochBlock = await bitcoinApi.$getBlock(quarterEpochBlockHash);
|
||||
this.quarterEpochBlockTime = quarterEpochBlock?.timestamp;
|
||||
} catch (e) {
|
||||
this.quarterEpochBlockTime = null;
|
||||
logger.warn('failed to update last epoch block time: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async $indexBlockByHeight(height: number, skipDb = false): Promise<BlockExtended> {
|
||||
if (Common.indexingEnabled() && !skipDb) {
|
||||
const dbBlock = await blocksRepository.$getBlockByHeight(height);
|
||||
if (dbBlock !== null) {
|
||||
return dbBlock;
|
||||
}
|
||||
}
|
||||
// not already indexed
|
||||
const hash = await bitcoinApi.$getBlockHash(height);
|
||||
return this.$indexBlock(hash);
|
||||
}
|
||||
|
||||
const blockHash = await bitcoinApi.$getBlockHash(height);
|
||||
const block: IEsploraApi.Block = await bitcoinApi.$getBlock(blockHash);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, block.timestamp, true);
|
||||
private async $handleReorgs(blockExtended: BlockExtended, timer: any): Promise<void> {
|
||||
let forkTail = blockExtended;
|
||||
let currentlyIndexed = await blocksRepository.$getBlockByHeight(forkTail.height - 1);
|
||||
this.updateTimerProgress(timer, `got block by height at previous tip ${forkTail.height - 1}`);
|
||||
|
||||
// previous blockhash is not what we expected: there has been a reorg
|
||||
if (currentlyIndexed !== null && forkTail.previousblockhash !== currentlyIndexed.id) {
|
||||
logger.warn(`Chain divergence detected at block ${blockExtended.height}, re-indexing most recent data`, logger.tags.mining);
|
||||
this.updateTimerProgress(timer, `reconnecting diverged chain from ${this.currentBlockHeight}`);
|
||||
const newBlocks: BlockExtended[] = [];
|
||||
// walk back along the chain until we reach the fork point
|
||||
while (currentlyIndexed !== null && forkTail.previousblockhash !== currentlyIndexed.id) {
|
||||
const newBlock = await this.$indexBlock(forkTail.previousblockhash);
|
||||
await blocksRepository.$setCanonicalBlockAtHeight(newBlock.id, newBlock.height);
|
||||
newBlocks.push(newBlock);
|
||||
this.updateTimerProgress(timer, `reindexed block at ${newBlock.height} (${newBlock.id})`);
|
||||
let newCpfpSummary;
|
||||
if (config.MEMPOOL.CPFP_INDEXING) {
|
||||
newCpfpSummary = await this.$indexCPFP(newBlock.id, newBlock.height);
|
||||
this.updateTimerProgress(timer, `reindexed block cpfp`);
|
||||
}
|
||||
await this.$getStrippedBlockTransactions(newBlock.id, true, true, newCpfpSummary, newBlock.height);
|
||||
this.updateTimerProgress(timer, `reindexed block summary`);
|
||||
|
||||
forkTail = newBlock;
|
||||
currentlyIndexed = await blocksRepository.$getBlockByHeight(forkTail.height - 1);
|
||||
this.updateTimerProgress(timer, `got block by height for ${forkTail.height - 1}`);
|
||||
}
|
||||
|
||||
// rebuild the block cache
|
||||
let currentBlock = forkTail;
|
||||
const cachedBlocksByHash = {};
|
||||
for (const cached of this.blocks) {
|
||||
cachedBlocksByHash[cached.id] = cached;
|
||||
}
|
||||
while (currentBlock.height > 0 && newBlocks.length < (config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4)) {
|
||||
const newBlock = cachedBlocksByHash[currentBlock.previousblockhash] || await blocksRepository.$getBlockByHash(currentBlock.previousblockhash);
|
||||
if (newBlock) {
|
||||
newBlocks.push(newBlock);
|
||||
currentBlock = newBlock;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.updateTimerProgress(timer, `rebuilt block cache`);
|
||||
|
||||
// force re-indexing of block-related data
|
||||
await HashratesRepository.$deleteHashratesFromTimestamp(forkTail.timestamp - 604800);
|
||||
await DifficultyAdjustmentsRepository.$deleteAdjustementsFromHeight(forkTail.height);
|
||||
await cpfpRepository.$deleteClustersFrom(forkTail.height);
|
||||
await AccelerationRepository.$deleteAccelerationsFrom(forkTail.height);
|
||||
chainTips.clearOrphanCacheAboveHeight(forkTail.height);
|
||||
this.updateTimerProgress(timer, `deleted stale block data`);
|
||||
|
||||
this.blocks = newBlocks.reverse();
|
||||
if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
|
||||
this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
|
||||
}
|
||||
this.updateTimerProgress(timer, `connected new best chain from ${forkTail.height} to ${this.currentBlockHeight}`);
|
||||
|
||||
await mining.$indexDifficultyAdjustments();
|
||||
this.updateTimerProgress(timer, `reindexed difficulty adjustments`);
|
||||
logger.info(`Re-indexed ${this.currentBlockHeight - forkTail.height} blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining);
|
||||
indexer.reindex();
|
||||
|
||||
websocketHandler.handleReorg();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a block if it's missing from the database. Returns the block after indexing
|
||||
*/
|
||||
public async $indexBlock(hash: string, block?: IEsploraApi.Block, skipDb = false): Promise<BlockExtended> {
|
||||
if (Common.indexingEnabled() && !skipDb) {
|
||||
const dbBlock = await blocksRepository.$getBlockByHash(hash);
|
||||
if (dbBlock !== null) {
|
||||
return dbBlock;
|
||||
}
|
||||
}
|
||||
|
||||
if (!block) {
|
||||
// dont' bother trying to fetch orphan blocks from esplora
|
||||
block = await (chainTips.isOrphaned(hash) ? bitcoinCoreApi.$getBlock(hash) : bitcoinApi.$getBlock(hash));
|
||||
}
|
||||
|
||||
const transactions = await this.$getTransactionsExtended(hash, block.height, block.timestamp, !block.stale, null, false, false, block.stale);
|
||||
const blockExtended = await this.$getBlockExtended(block, transactions);
|
||||
if (block.stale) {
|
||||
blockExtended.canonical = await bitcoinApi.$getBlockHash(block.height);
|
||||
}
|
||||
|
||||
if (Common.indexingEnabled()) {
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||
|
|
@ -1121,38 +1269,25 @@ class Blocks {
|
|||
return blockExtended;
|
||||
}
|
||||
|
||||
public async $indexStaleBlock(hash: string): Promise<BlockExtended> {
|
||||
const block: IEsploraApi.Block = await bitcoinApi.$getBlock(hash);
|
||||
const transactions = await this.$getTransactionsExtended(hash, block.height, block.timestamp, true);
|
||||
const blockExtended = await this.$getBlockExtended(block, transactions);
|
||||
|
||||
blockExtended.canonical = await bitcoinApi.$getBlockHash(block.height);
|
||||
|
||||
return blockExtended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one block by its hash
|
||||
*/
|
||||
public async $getBlock(hash: string): Promise<BlockExtended | IEsploraApi.Block> {
|
||||
public async $getBlock(hash: string, skipMemoryCache: boolean = false): Promise<BlockExtended | IEsploraApi.Block> {
|
||||
// Check the memory cache
|
||||
const blockByHash = this.getBlocks().find((b) => b.id === hash);
|
||||
if (blockByHash) {
|
||||
return blockByHash;
|
||||
if (!skipMemoryCache) {
|
||||
const blockByHash = this.getBlocks().find((b) => b.id === hash);
|
||||
if (blockByHash) {
|
||||
return blockByHash;
|
||||
}
|
||||
}
|
||||
|
||||
// Not Bitcoin network, return the block as it from the bitcoin backend
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK) === false) {
|
||||
return await bitcoinCoreApi.$getBlock(hash);
|
||||
}
|
||||
|
||||
// Bitcoin network, add our custom data on top
|
||||
const block: IEsploraApi.Block = await bitcoinApi.$getBlock(hash);
|
||||
if (block.stale) {
|
||||
return await this.$indexStaleBlock(hash);
|
||||
} else {
|
||||
return await this.$indexBlock(block.height);
|
||||
}
|
||||
return await this.$indexBlock(hash);
|
||||
}
|
||||
|
||||
public async $getStrippedBlockTransactions(hash: string, skipMemoryCache = false,
|
||||
|
|
@ -1200,20 +1335,19 @@ class Blocks {
|
|||
};
|
||||
summaryVersion = cpfpSummary.version;
|
||||
} else {
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
const txs = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx));
|
||||
summary = this.summarizeBlockTransactions(hash, height || 0, txs);
|
||||
summaryVersion = 1;
|
||||
} else {
|
||||
// Call Core RPC
|
||||
const block = await bitcoinClient.getBlock(hash, 2);
|
||||
summary = this.summarizeBlock(block);
|
||||
height = block.height;
|
||||
}
|
||||
const txs = (await bitcoinApi.$getTxsForBlock(hash, true)).map(tx => transactionUtils.extendTransaction(tx));
|
||||
summary = this.summarizeBlockTransactions(hash, height || 0, txs);
|
||||
summaryVersion = 1;
|
||||
}
|
||||
if (height == null) {
|
||||
const block = await bitcoinApi.$getBlock(hash);
|
||||
height = block.height;
|
||||
// If the block is orphaned, use the height from the chaintips cache
|
||||
const orphanedBlock = chainTips.getOrphanedBlock(hash);
|
||||
if (orphanedBlock) {
|
||||
height = orphanedBlock.height;
|
||||
} else {
|
||||
const block = await bitcoinApi.$getBlock(hash);
|
||||
height = block.height;
|
||||
}
|
||||
}
|
||||
|
||||
// Index the response if needed
|
||||
|
|
@ -1260,7 +1394,7 @@ class Blocks {
|
|||
returnBlocks.push(block);
|
||||
} else {
|
||||
// Using indexing (find by height, index on the fly, save in database)
|
||||
block = await this.$indexBlock(currentHeight);
|
||||
block = await this.$indexBlockByHeight(currentHeight);
|
||||
returnBlocks.push(block);
|
||||
}
|
||||
currentHeight--;
|
||||
|
|
@ -1285,7 +1419,7 @@ class Blocks {
|
|||
while (fromHeight <= toHeight) {
|
||||
let block: BlockExtended | null = await blocksRepository.$getBlockByHeight(fromHeight);
|
||||
if (!block) {
|
||||
await this.$indexBlock(fromHeight);
|
||||
await this.$indexBlockByHeight(fromHeight);
|
||||
block = await blocksRepository.$getBlockByHeight(fromHeight);
|
||||
if (!block) {
|
||||
continue;
|
||||
|
|
@ -1342,7 +1476,7 @@ class Blocks {
|
|||
let summary;
|
||||
let summaryVersion = 0;
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
const txs = (await bitcoinApi.$getTxsForBlock(cleanBlock.hash)).map(tx => transactionUtils.extendTransaction(tx));
|
||||
const txs = (await bitcoinApi.$getTxsForBlock(cleanBlock.hash, cleanBlock.stale)).map(tx => transactionUtils.extendTransaction(tx));
|
||||
summary = this.summarizeBlockTransactions(cleanBlock.hash, cleanBlock.height, txs);
|
||||
summaryVersion = 1;
|
||||
} else {
|
||||
|
|
@ -1391,7 +1525,7 @@ class Blocks {
|
|||
}
|
||||
|
||||
public async $getBlockAuditSummary(hash: string): Promise<BlockAudit | null> {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && Common.auditIndexingEnabled()) {
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK) && Common.auditIndexingEnabled()) {
|
||||
return BlocksAuditsRepository.$getBlockAudit(hash);
|
||||
} else {
|
||||
return null;
|
||||
|
|
@ -1399,7 +1533,7 @@ class Blocks {
|
|||
}
|
||||
|
||||
public async $getBlockTxAuditSummary(hash: string, txid: string): Promise<TransactionAudit | null> {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && Common.auditIndexingEnabled()) {
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK) && Common.auditIndexingEnabled()) {
|
||||
return BlocksAuditsRepository.$getBlockTxAudit(hash, txid);
|
||||
} else {
|
||||
return null;
|
||||
|
|
@ -1422,11 +1556,11 @@ class Blocks {
|
|||
return this.currentBlockHeight;
|
||||
}
|
||||
|
||||
public async $indexCPFP(hash: string, height: number, txs?: MempoolTransactionExtended[]): Promise<CpfpSummary | null> {
|
||||
public async $indexCPFP(hash: string, height: number, txs?: MempoolTransactionExtended[], stale?: boolean): Promise<CpfpSummary | null> {
|
||||
let transactions = txs;
|
||||
if (!transactions) {
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
transactions = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendMempoolTransaction(tx));
|
||||
transactions = (await bitcoinApi.$getTxsForBlock(hash, true)).map(tx => transactionUtils.extendMempoolTransaction(tx));
|
||||
}
|
||||
if (!transactions) {
|
||||
const block = await bitcoinClient.getBlock(hash, 2);
|
||||
|
|
@ -1440,7 +1574,9 @@ class Blocks {
|
|||
if (transactions?.length != null) {
|
||||
const summary = calculateFastBlockCpfp(height, transactions);
|
||||
|
||||
await this.$saveCpfp(hash, height, summary);
|
||||
if (!stale) {
|
||||
await this.$saveCpfp(hash, height, summary);
|
||||
}
|
||||
|
||||
const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions);
|
||||
await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats);
|
||||
|
|
@ -1465,7 +1601,7 @@ class Blocks {
|
|||
|
||||
public async $getBlockDefinitionHashes(): Promise<string[] | null> {
|
||||
try {
|
||||
const [rows]: any = await database.query(`SELECT DISTINCT(definition_hash) FROM blocks`);
|
||||
const [rows]: any = await database.query(`SELECT DISTINCT(definition_hash) FROM blocks WHERE stale = 0`);
|
||||
if (rows && Array.isArray(rows)) {
|
||||
return rows.map(r => r.definition_hash);
|
||||
} else {
|
||||
|
|
@ -1480,7 +1616,7 @@ class Blocks {
|
|||
|
||||
public async $getBlocksByDefinitionHash(definitionHash: string): Promise<string[] | null> {
|
||||
try {
|
||||
const [rows]: any = await database.query(`SELECT hash FROM blocks WHERE definition_hash = ?`, [definitionHash]);
|
||||
const [rows]: any = await database.query(`SELECT hash FROM blocks WHERE definition_hash = ? AND stale = 0`, [definitionHash]);
|
||||
if (rows && Array.isArray(rows)) {
|
||||
return rows.map(r => r.hash);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import config from '../config';
|
||||
import logger from '../logger';
|
||||
import { BlockExtended } from '../mempool.interfaces';
|
||||
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||
import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||
import blocks from './blocks';
|
||||
import { Common } from './common';
|
||||
|
||||
export interface ChainTip {
|
||||
height: number;
|
||||
|
|
@ -8,6 +15,11 @@ export interface ChainTip {
|
|||
status: 'invalid' | 'active' | 'valid-fork' | 'valid-headers' | 'headers-only';
|
||||
};
|
||||
|
||||
export interface StaleTip extends ChainTip {
|
||||
stale: BlockExtended;
|
||||
canonical: BlockExtended;
|
||||
}
|
||||
|
||||
export interface OrphanedBlock {
|
||||
height: number;
|
||||
hash: string;
|
||||
|
|
@ -17,18 +29,31 @@ export interface OrphanedBlock {
|
|||
|
||||
class ChainTips {
|
||||
private chainTips: ChainTip[] = [];
|
||||
private staleTips: Record<number, StaleTip> = {};
|
||||
private orphanedBlocks: { [hash: string]: OrphanedBlock } = {};
|
||||
private blockCache: { [hash: string]: OrphanedBlock } = {};
|
||||
private orphansByHeight: { [height: number]: OrphanedBlock[] } = {};
|
||||
private indexingOrphanedBlocks = false;
|
||||
private indexingQueue: { blockhash?: string, block?: IEsploraApi.Block, tip: OrphanedBlock }[] = [];
|
||||
|
||||
private staleTipsCacheSize = 50;
|
||||
private maxIndexingQueueSize = 100;
|
||||
|
||||
public async updateOrphanedBlocks(): Promise<void> {
|
||||
try {
|
||||
this.chainTips = await bitcoinClient.getChainTips();
|
||||
|
||||
const activeTipHeight = this.chainTips.find(tip => tip.status === 'active')?.height || (await bitcoinApi.$getBlockHeightTip());
|
||||
let minIndexHeight = 0;
|
||||
const indexedBlockAmount = Math.min(config.MEMPOOL.INDEXING_BLOCKS_AMOUNT, activeTipHeight);
|
||||
if (indexedBlockAmount > 0) {
|
||||
minIndexHeight = Math.max(0, activeTipHeight - indexedBlockAmount + 1);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const breakAt = start + 10000;
|
||||
let newOrphans = 0;
|
||||
this.orphanedBlocks = {};
|
||||
const newOrphanedBlocks = {};
|
||||
|
||||
for (const chain of this.chainTips) {
|
||||
if (chain.status === 'valid-fork' || chain.status === 'valid-headers') {
|
||||
|
|
@ -37,16 +62,35 @@ class ChainTips {
|
|||
do {
|
||||
let orphan = this.blockCache[hash];
|
||||
if (!orphan) {
|
||||
const block = await bitcoinClient.getBlock(hash);
|
||||
if (block && block.confirmations === -1) {
|
||||
const block = await bitcoinCoreApi.$getBlock(hash);
|
||||
if (block && block.stale) {
|
||||
newOrphans++;
|
||||
orphan = {
|
||||
height: block.height,
|
||||
hash: block.hash,
|
||||
hash: block.id,
|
||||
status: chain.status,
|
||||
prevhash: block.previousblockhash,
|
||||
};
|
||||
this.blockCache[hash] = orphan;
|
||||
// don't index stale blocks below the INDEXING_BLOCKS_AMOUNT cutoff
|
||||
if (block.height >= minIndexHeight) {
|
||||
if (this.indexingQueue.length < this.maxIndexingQueueSize) {
|
||||
this.indexingQueue.push({ block, tip: orphan });
|
||||
} else {
|
||||
// re-fetch blocks lazily if the queue is big to keep memory usage sane
|
||||
this.indexingQueue.push({ blockhash: hash, tip: orphan });
|
||||
}
|
||||
}
|
||||
// make sure the cached canonical block at this height is correct & up to date
|
||||
if (block.height >= (activeTipHeight - (config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4))) {
|
||||
const cachedBlocks = blocks.getBlocks();
|
||||
for (const cachedBlock of cachedBlocks) {
|
||||
if (cachedBlock.height === block.height) {
|
||||
// ensure this stale block is included in the orphans list
|
||||
cachedBlock.extras.orphans = Array.from(new Set([...(cachedBlock.extras.orphans || []), orphan]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (orphan) {
|
||||
|
|
@ -55,7 +99,7 @@ class ChainTips {
|
|||
hash = orphan?.prevhash;
|
||||
} while (hash && (Date.now() < breakAt));
|
||||
for (const orphan of orphans) {
|
||||
this.orphanedBlocks[orphan.hash] = orphan;
|
||||
newOrphanedBlocks[orphan.hash] = orphan;
|
||||
}
|
||||
}
|
||||
if (Date.now() >= breakAt) {
|
||||
|
|
@ -65,6 +109,7 @@ class ChainTips {
|
|||
}
|
||||
|
||||
this.orphansByHeight = {};
|
||||
this.orphanedBlocks = newOrphanedBlocks;
|
||||
const allOrphans = Object.values(this.orphanedBlocks);
|
||||
for (const orphan of allOrphans) {
|
||||
if (!this.orphansByHeight[orphan.height]) {
|
||||
|
|
@ -73,12 +118,82 @@ class ChainTips {
|
|||
this.orphansByHeight[orphan.height].push(orphan);
|
||||
}
|
||||
|
||||
const heightsToKeep = new Set(this.chainTips.filter(tip => tip.status !== 'active').map(tip => tip.height));
|
||||
const heightsToRemove: number[] = Object.keys(this.staleTips).map(Number).filter(height => !heightsToKeep.has(height));
|
||||
for (const height of heightsToRemove) {
|
||||
delete this.staleTips[height];
|
||||
}
|
||||
|
||||
this.trimStaleTipsCache();
|
||||
|
||||
// index new orphaned blocks in the background
|
||||
void this.$indexOrphanedBlocks();
|
||||
|
||||
logger.debug(`Updated orphaned blocks cache. Fetched ${newOrphans} new orphaned blocks. Total ${allOrphans.length}`);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get fetch orphaned blocks. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async $indexOrphanedBlocks(): Promise<void> {
|
||||
if (this.indexingOrphanedBlocks) {
|
||||
return;
|
||||
}
|
||||
this.indexingOrphanedBlocks = true;
|
||||
while (this.indexingQueue.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, prefer-const
|
||||
let { blockhash, block, tip } = this.indexingQueue.shift()!;
|
||||
if (!block && !blockhash) {
|
||||
continue;
|
||||
}
|
||||
if (blockhash && !block) {
|
||||
block = await bitcoinCoreApi.$getBlock(blockhash);
|
||||
}
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
let staleBlock: BlockExtended | undefined;
|
||||
const alreadyIndexed = await BlocksSummariesRepository.$isSummaryIndexed(block.id);
|
||||
const needToCache = Object.keys(this.staleTips).length < this.staleTipsCacheSize || block.height > Object.keys(this.staleTips).map(Number).sort((a, b) => b - a)[this.staleTipsCacheSize - 1];
|
||||
if (!alreadyIndexed) {
|
||||
staleBlock = await blocks.$indexBlock(block.id, block, true);
|
||||
await blocks.$indexBlockSummary(block.id, block.height, true);
|
||||
// don't DDOS core by indexing too fast
|
||||
await Common.sleep$(5000);
|
||||
} else if (needToCache) {
|
||||
staleBlock = await blocks.$getBlock(block.id, true) as BlockExtended;
|
||||
}
|
||||
|
||||
if (staleBlock && needToCache) {
|
||||
const canonicalBlock = await blocks.$indexBlockByHeight(staleBlock.height);
|
||||
this.staleTips[staleBlock.height] = {
|
||||
height: staleBlock.height,
|
||||
hash: staleBlock.id,
|
||||
branchlen: tip.height - staleBlock.height,
|
||||
status: tip.status,
|
||||
stale: staleBlock,
|
||||
canonical: canonicalBlock,
|
||||
};
|
||||
this.trimStaleTipsCache();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Failed to index orphaned block ${block.id} at height ${block.height}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
this.indexingOrphanedBlocks = false;
|
||||
}
|
||||
|
||||
private trimStaleTipsCache(): void {
|
||||
const staleTipHeights = Object.keys(this.staleTips).map(Number).sort((a, b) => b - a);
|
||||
if (staleTipHeights.length > this.staleTipsCacheSize) {
|
||||
const heightsToDiscard = staleTipHeights.slice(this.staleTipsCacheSize);
|
||||
for (const height of heightsToDiscard) {
|
||||
delete this.staleTips[height];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getOrphanedBlocksAtHeight(height: number | undefined): OrphanedBlock[] {
|
||||
if (height === undefined) {
|
||||
return [];
|
||||
|
|
@ -86,6 +201,35 @@ class ChainTips {
|
|||
|
||||
return this.orphansByHeight[height] || [];
|
||||
}
|
||||
|
||||
public getChainTips(): ChainTip[] {
|
||||
return this.chainTips;
|
||||
}
|
||||
|
||||
public getStaleTips(): StaleTip[] {
|
||||
return Object.values(this.staleTips).sort((a, b) => b.height - a.height);
|
||||
}
|
||||
|
||||
clearOrphanCacheAboveHeight(height: number): void {
|
||||
for (const h in this.orphansByHeight) {
|
||||
if (Number(h) > height) {
|
||||
const orphans = this.orphansByHeight[h];
|
||||
delete this.orphansByHeight[h];
|
||||
for (const o of orphans) {
|
||||
delete this.orphanedBlocks[o.hash];
|
||||
delete this.blockCache[o.hash];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public isOrphaned(hash: string): boolean {
|
||||
return !!this.orphanedBlocks[hash] || this.blockCache[hash]?.status === 'valid-fork' || this.blockCache[hash]?.status === 'valid-headers';
|
||||
}
|
||||
|
||||
public getOrphanedBlock(hash: string): OrphanedBlock | undefined {
|
||||
return this.orphanedBlocks[hash] || this.blockCache[hash];
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChainTips();
|
||||
|
|
@ -24,15 +24,15 @@ const MAX_STANDARD_SCRIPTSIG_SIZE = 1650;
|
|||
const DUST_RELAY_TX_FEE = 3;
|
||||
const MAX_OP_RETURN_RELAY = 83;
|
||||
const DEFAULT_PERMIT_BAREMULTISIG = true;
|
||||
const MAX_TX_LEGACY_SIGOPS = 2_500 * 4; // witness-adjusted sigops
|
||||
|
||||
export class Common {
|
||||
static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ?
|
||||
'144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49'
|
||||
: '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d';
|
||||
static _isLiquid = config.MEMPOOL.NETWORK === 'liquid' || config.MEMPOOL.NETWORK === 'liquidtestnet';
|
||||
|
||||
static isLiquid(): boolean {
|
||||
return this._isLiquid;
|
||||
return config?.MEMPOOL?.NETWORK === 'liquid' || config?.MEMPOOL?.NETWORK === 'liquidtestnet';
|
||||
}
|
||||
|
||||
static median(numbers: number[]) {
|
||||
|
|
@ -225,6 +225,11 @@ export class Common {
|
|||
return true;
|
||||
}
|
||||
|
||||
// legacy sigops
|
||||
if (this.isNonStandardLegacySigops(tx, height)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// input validation
|
||||
for (const vin of tx.vin) {
|
||||
if (vin.is_coinbase) {
|
||||
|
|
@ -236,7 +241,7 @@ export class Common {
|
|||
return true;
|
||||
}
|
||||
// scriptsig-not-pushonly
|
||||
if (vin.scriptsig_asm) {
|
||||
if (vin.scriptsig_asm?.length) {
|
||||
for (const op of vin.scriptsig_asm.split(' ')) {
|
||||
if (opcodes[op] && opcodes[op] > opcodes['OP_16']) {
|
||||
return true;
|
||||
|
|
@ -286,6 +291,7 @@ export class Common {
|
|||
|
||||
// output validation
|
||||
let opreturnCount = 0;
|
||||
let opreturnBytes = 0;
|
||||
for (const vout of tx.vout) {
|
||||
// scriptpubkey
|
||||
if (['nonstandard', 'provably_unspendable', 'empty'].includes(vout.scriptpubkey_type)) {
|
||||
|
|
@ -309,10 +315,7 @@ export class Common {
|
|||
}
|
||||
} else if (vout.scriptpubkey_type === 'op_return') {
|
||||
opreturnCount++;
|
||||
if ((vout.scriptpubkey.length / 2) > MAX_OP_RETURN_RELAY) {
|
||||
// over default datacarrier limit
|
||||
return true;
|
||||
}
|
||||
opreturnBytes += vout.scriptpubkey.length / 2;
|
||||
}
|
||||
// dust
|
||||
// (we could probably hardcode this for the different output types...)
|
||||
|
|
@ -334,9 +337,11 @@ export class Common {
|
|||
}
|
||||
}
|
||||
|
||||
// multi-op-return
|
||||
if (opreturnCount > 1) {
|
||||
return true;
|
||||
// op_return
|
||||
if (opreturnCount > 0) {
|
||||
if (!this.isStandardOpReturn(opreturnBytes, opreturnCount, height)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: non-mandatory-script-verify-flag
|
||||
|
|
@ -429,6 +434,49 @@ export class Common {
|
|||
return false;
|
||||
}
|
||||
|
||||
// OP_RETURN size & count limits were lifted in v28.3/v29.2/v30.0
|
||||
static OP_RETURN_STANDARDNESS_ACTIVATION_HEIGHT = {
|
||||
'testnet4': 108_000,
|
||||
'testnet': 4_750_000,
|
||||
'signet': 276_500,
|
||||
'': 921_000,
|
||||
};
|
||||
static MAX_DATACARRIER_BYTES = 83;
|
||||
static isStandardOpReturn(bytes: number, outputs: number,height?: number): boolean {
|
||||
if (
|
||||
(height == null || (
|
||||
this.OP_RETURN_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
|
||||
&& height >= this.OP_RETURN_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
|
||||
)) // limits lifted
|
||||
|| // OR
|
||||
(bytes <= this.MAX_DATACARRIER_BYTES && outputs <= 1) // below old limits
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// New legacy sigops limit started to be enforced in v30.0
|
||||
static LEGACY_SIGOPS_STANDARDNESS_ACTIVATION_HEIGHT = {
|
||||
'testnet4': 108_000,
|
||||
'testnet': 4_750_000,
|
||||
'signet': 276_500,
|
||||
'': 921_000,
|
||||
};
|
||||
static isNonStandardLegacySigops(tx: TransactionExtended, height?: number): boolean {
|
||||
if (
|
||||
height == null || (
|
||||
this.LEGACY_SIGOPS_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
|
||||
&& height >= this.LEGACY_SIGOPS_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
|
||||
)
|
||||
) {
|
||||
if (!transactionUtils.checkSigopsBIP54(tx, MAX_TX_LEGACY_SIGOPS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static getNonWitnessSize(tx: TransactionExtended): number {
|
||||
let weight = tx.weight;
|
||||
let hasWitness = false;
|
||||
|
|
@ -460,7 +508,7 @@ export class Common {
|
|||
}
|
||||
|
||||
static setLegacySighashFlags(flags: bigint, scriptsig_asm: string): bigint {
|
||||
for (const item of scriptsig_asm.split(' ')) {
|
||||
for (const item of scriptsig_asm?.split(' ') ?? []) {
|
||||
// skip op_codes
|
||||
if (item.startsWith('OP_')) {
|
||||
continue;
|
||||
|
|
@ -806,7 +854,7 @@ export class Common {
|
|||
|
||||
static indexingEnabled(): boolean {
|
||||
return (
|
||||
['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) &&
|
||||
['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK) &&
|
||||
config.DATABASE.ENABLED === true &&
|
||||
config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== 0
|
||||
);
|
||||
|
|
@ -885,6 +933,13 @@ export class Common {
|
|||
}
|
||||
|
||||
static findSocketNetwork(addr: string): {network: string | null, url: string} {
|
||||
if (!addr?.length) {
|
||||
return {
|
||||
network: null,
|
||||
url: ''
|
||||
};
|
||||
}
|
||||
|
||||
let network: string | null = null;
|
||||
let url: string = addr;
|
||||
|
||||
|
|
@ -892,7 +947,7 @@ export class Common {
|
|||
url = addr.split('://')[1];
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
if (!url?.length) {
|
||||
return {
|
||||
network: null,
|
||||
url: addr,
|
||||
|
|
@ -918,7 +973,15 @@ export class Common {
|
|||
};
|
||||
}
|
||||
} else if (addr.indexOf('ipv6') !== -1 || (config.LIGHTNING.BACKEND === 'lnd' && url.indexOf(']:'))) {
|
||||
url = url.split('[')[1].split(']')[0];
|
||||
const parts = url.split('[');
|
||||
if (parts.length < 2) {
|
||||
return {
|
||||
network: null,
|
||||
url: addr,
|
||||
};
|
||||
} else {
|
||||
url = parts[1].split(']')[0];
|
||||
}
|
||||
const ipv = isIP(url);
|
||||
if (ipv === 6) {
|
||||
const parts = addr.split(':');
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
|
|||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 101;
|
||||
private static currentVersion = 104;
|
||||
private queryTimeout = 3600_000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
|
|
@ -104,7 +104,7 @@ class DatabaseMigration {
|
|||
private async $createMissingTablesAndIndexes(databaseSchemaVersion: number) {
|
||||
await this.$setStatisticsAddedIndexedFlag(databaseSchemaVersion);
|
||||
|
||||
const isBitcoin = ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK);
|
||||
const isBitcoin = ['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK);
|
||||
|
||||
await this.$executeQuery(this.getCreateElementsTableQuery(), await this.$checkIfTableExists('elements_pegs'));
|
||||
await this.$executeQuery(this.getCreateStatisticsQuery(), await this.$checkIfTableExists('statistics'));
|
||||
|
|
@ -1166,6 +1166,17 @@ class DatabaseMigration {
|
|||
if (databaseSchemaVersion < 100) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD index_version INT NOT NULL DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `index_version` (`index_version`)');
|
||||
await this.updateToSchemaVersion(100);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 102) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD stale BOOL NOT NULL DEFAULT 0');
|
||||
await this.updateToSchemaVersion(102);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 103) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `stale` (`stale`)');
|
||||
await this.updateToSchemaVersion(103);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1275,7 +1286,7 @@ class DatabaseMigration {
|
|||
*/
|
||||
private getMigrationQueriesFromVersion(version: number): string[] {
|
||||
const queries: string[] = [];
|
||||
const isBitcoin = ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK);
|
||||
const isBitcoin = ['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK);
|
||||
|
||||
if (version < 1) {
|
||||
if (config.MEMPOOL.NETWORK !== 'liquid' && config.MEMPOOL.NETWORK !== 'liquidtestnet') {
|
||||
|
|
@ -1303,6 +1314,12 @@ class DatabaseMigration {
|
|||
queries.push(`DELETE FROM prices WHERE USD = -1`);
|
||||
}
|
||||
|
||||
if (version < 104) {
|
||||
queries.push(`ALTER TABLE blocks DROP PRIMARY KEY`);
|
||||
queries.push(`ALTER TABLE blocks ADD PRIMARY KEY (hash)`);
|
||||
queries.push(`ALTER TABLE blocks ADD INDEX (height)`);
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ class DiskCache {
|
|||
}
|
||||
|
||||
if (rbfData?.rbf) {
|
||||
rbfCache.load({
|
||||
await rbfCache.load({
|
||||
txs: rbfData.rbf.txs.map(([txid, entry]) => ({ value: entry })),
|
||||
trees: rbfData.rbf.trees,
|
||||
expiring: rbfData.rbf.expiring.map(([txid, value]) => ({ key: txid, value })),
|
||||
|
|
|
|||
|
|
@ -580,6 +580,9 @@ class ChannelsApi {
|
|||
* Save or update a channel present in the graph
|
||||
*/
|
||||
public async $saveChannel(channel: ILightningApi.Channel, status = 1): Promise<void> {
|
||||
if (!channel.chan_point?.length) {
|
||||
return;
|
||||
}
|
||||
const [ txid, vout ] = channel.chan_point.split(':');
|
||||
|
||||
const policy1: Partial<ILightningApi.RoutingPolicy> = channel.node1_policy || {};
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ class NodesRoutes {
|
|||
return;
|
||||
}
|
||||
|
||||
const nodes = await nodesApi.$getNodesPerISP(req.params.isp);
|
||||
const nodes = await nodesApi.$getNodesPerISP(req.params.isp || '');
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ interface RecommendedFees {
|
|||
class FeeApi {
|
||||
constructor() { }
|
||||
|
||||
defaultFee = isLiquid ? 0.1 : 1;
|
||||
minimumIncrement = isLiquid ? 0.1 : 1;
|
||||
minFastestFee = isLiquid ? 0.1 : 1;
|
||||
minHalfHourFee = isLiquid ? 0.1 : 0.5;
|
||||
priorityFactor = isLiquid ? 0 : 0.5;
|
||||
|
||||
public getRecommendedFee(): RecommendedFees {
|
||||
const pBlocks = projectedBlocks.getMempoolBlocks();
|
||||
|
|
@ -27,24 +29,46 @@ class FeeApi {
|
|||
return this.calculateRecommendedFee(pBlocks, mPool);
|
||||
}
|
||||
|
||||
public calculateRecommendedFee(pBlocks: MempoolBlock[], mPool: IBitcoinApi.MempoolInfo): RecommendedFees {
|
||||
const minimumFee = this.roundUpToNearest(mPool.mempoolminfee * 100000, this.minimumIncrement);
|
||||
const defaultMinFee = Math.max(minimumFee, this.defaultFee);
|
||||
public getPreciseRecommendedFee(): RecommendedFees {
|
||||
const pBlocks = projectedBlocks.getMempoolBlocks();
|
||||
const mPool = mempool.getMempoolInfo();
|
||||
|
||||
// minimum non-zero minrelaytxfee / incrementalrelayfee is 1 sat/kvB = 0.001 sat/vB
|
||||
const recommendations = this.calculateRecommendedFee(pBlocks, mPool, 0.001);
|
||||
// enforce floor & offset for highest priority recommendations while <100% hashrate accepts sub-sat fees
|
||||
recommendations.fastestFee = Math.max(recommendations.fastestFee + this.priorityFactor, this.minFastestFee);
|
||||
recommendations.halfHourFee = Math.max(recommendations.halfHourFee + (this.priorityFactor / 2), this.minHalfHourFee);
|
||||
return {
|
||||
'fastestFee': Math.round(recommendations.fastestFee * 1000) / 1000,
|
||||
'halfHourFee': Math.round(recommendations.halfHourFee * 1000) / 1000,
|
||||
'hourFee': Math.round(recommendations.hourFee * 1000) / 1000,
|
||||
'economyFee': Math.round(recommendations.economyFee * 1000) / 1000,
|
||||
'minimumFee': Math.round(recommendations.minimumFee * 1000) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
public calculateRecommendedFee(pBlocks: MempoolBlock[], mPool: IBitcoinApi.MempoolInfo, minIncrement: number = this.minimumIncrement): RecommendedFees {
|
||||
const purgeRate = this.roundUpToNearest(mPool.mempoolminfee * 100000, minIncrement);
|
||||
const minimumFee = Math.max(purgeRate, minIncrement);
|
||||
|
||||
if (!pBlocks.length) {
|
||||
return {
|
||||
'fastestFee': defaultMinFee,
|
||||
'halfHourFee': defaultMinFee,
|
||||
'hourFee': defaultMinFee,
|
||||
'fastestFee': minimumFee,
|
||||
'halfHourFee': minimumFee,
|
||||
'hourFee': minimumFee,
|
||||
'economyFee': minimumFee,
|
||||
'minimumFee': minimumFee,
|
||||
};
|
||||
}
|
||||
|
||||
const firstMedianFee = this.optimizeMedianFee(pBlocks[0], pBlocks[1]);
|
||||
const secondMedianFee = pBlocks[1] ? this.optimizeMedianFee(pBlocks[1], pBlocks[2], firstMedianFee) : this.defaultFee;
|
||||
const thirdMedianFee = pBlocks[2] ? this.optimizeMedianFee(pBlocks[2], pBlocks[3], secondMedianFee) : this.defaultFee;
|
||||
const firstMedianFee = this.optimizeMedianFee(pBlocks[0], pBlocks[1], undefined, minimumFee, minIncrement);
|
||||
const secondMedianFee = pBlocks[1] ? this.optimizeMedianFee(pBlocks[1], pBlocks[2], firstMedianFee, minimumFee, minIncrement) : minimumFee;
|
||||
const thirdMedianFee = pBlocks[2] ? this.optimizeMedianFee(pBlocks[2], pBlocks[3], secondMedianFee, minimumFee, minIncrement) : minimumFee;
|
||||
|
||||
// explicitly enforce a minimum of ceil(mempoolminfee) on all recommendations.
|
||||
// simply rounding up recommended rates is insufficient, as the purging rate
|
||||
// can exceed the median rate of projected blocks in some extreme scenarios
|
||||
// (see https://bitcoin.stackexchange.com/a/120024)
|
||||
let fastestFee = Math.max(minimumFee, firstMedianFee);
|
||||
let halfHourFee = Math.max(minimumFee, secondMedianFee);
|
||||
let hourFee = Math.max(minimumFee, thirdMedianFee);
|
||||
|
|
@ -55,33 +79,39 @@ class FeeApi {
|
|||
halfHourFee = Math.max(halfHourFee, hourFee, economyFee);
|
||||
hourFee = Math.max(hourFee, economyFee);
|
||||
|
||||
// explicitly enforce a minimum of ceil(mempoolminfee) on all recommendations.
|
||||
// simply rounding up recommended rates is insufficient, as the purging rate
|
||||
// can exceed the median rate of projected blocks in some extreme scenarios
|
||||
// (see https://bitcoin.stackexchange.com/a/120024)
|
||||
return {
|
||||
'fastestFee': fastestFee,
|
||||
'halfHourFee': halfHourFee,
|
||||
'hourFee': hourFee,
|
||||
'economyFee': economyFee,
|
||||
'minimumFee': minimumFee,
|
||||
'fastestFee': this.roundToNearest(fastestFee, minIncrement),
|
||||
'halfHourFee': this.roundToNearest(halfHourFee, minIncrement),
|
||||
'hourFee': this.roundToNearest(hourFee, minIncrement),
|
||||
'economyFee': this.roundToNearest(economyFee, minIncrement),
|
||||
'minimumFee': this.roundToNearest(minimumFee, minIncrement),
|
||||
};
|
||||
}
|
||||
|
||||
private optimizeMedianFee(pBlock: MempoolBlock, nextBlock: MempoolBlock | undefined, previousFee?: number): number {
|
||||
private optimizeMedianFee(pBlock: MempoolBlock, nextBlock: MempoolBlock | undefined, previousFee: number | undefined, minFee: number, minIncrement: number = this.minimumIncrement): number {
|
||||
const useFee = previousFee ? (pBlock.medianFee + previousFee) / 2 : pBlock.medianFee;
|
||||
if (pBlock.blockVSize <= 500000 || pBlock.medianFee < 1) {
|
||||
return this.defaultFee;
|
||||
if (pBlock.blockVSize <= 500000 || pBlock.medianFee < minFee) {
|
||||
return minFee;
|
||||
}
|
||||
if (pBlock.blockVSize <= 950000 && !nextBlock) {
|
||||
const multiplier = (pBlock.blockVSize - 500000) / 500000;
|
||||
return Math.max(Math.round(useFee * multiplier), this.defaultFee);
|
||||
return Math.max(this.roundToNearest(useFee * multiplier, minIncrement), minFee);
|
||||
}
|
||||
return this.roundUpToNearest(useFee, this.minimumIncrement);
|
||||
return Math.max(this.roundUpToNearest(useFee, minIncrement), minFee);
|
||||
}
|
||||
|
||||
private roundUpToNearest(value: number, nearest: number): number {
|
||||
return Math.ceil(value / nearest) * nearest;
|
||||
if (nearest !== 0) {
|
||||
return Math.ceil(value / nearest) * nearest;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private roundToNearest(value: number, nearest: number): number {
|
||||
if (nearest !== 0) {
|
||||
return Math.round(value / nearest) * nearest;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ class LiquidRoutes {
|
|||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
if (['testnet', 'signet', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (['testnet', 'signet', 'liquidtestnet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
handleError(req, res, 400, 'Prices are not available on testnets.');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class MempoolBlocks {
|
|||
}
|
||||
|
||||
public async updatePools$(): Promise<void> {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK) === false) {
|
||||
this.pools = {};
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ class Mempool {
|
|||
private mempoolCandidates: { [txid: string ]: boolean } = {};
|
||||
private spendMap = new Map<string, MempoolTransactionExtended>();
|
||||
private recentlyDeleted: MempoolTransactionExtended[][] = []; // buffer of transactions deleted in recent mempool updates
|
||||
private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0, total_fee: 0,
|
||||
maxmempool: 300000000, mempoolminfee: Common.isLiquid() ? 0.00000100 : 0.00001000, minrelaytxfee: Common.isLiquid() ? 0.00000100 : 0.00001000 };
|
||||
private mempoolInfo: IBitcoinApi.MempoolInfo;
|
||||
private mempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, newTransactions: MempoolTransactionExtended[],
|
||||
deletedTransactions: MempoolTransactionExtended[][], accelerationDelta: string[]) => void) | undefined;
|
||||
private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, mempoolSize: number, newTransactions: MempoolTransactionExtended[],
|
||||
|
|
@ -44,11 +43,36 @@ class Mempool {
|
|||
private timer = new Date().getTime();
|
||||
private missingTxCount = 0;
|
||||
private mainLoopTimeout: number = 120000;
|
||||
private txPerSecondInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
public limitGBT = config.MEMPOOL.USE_SECOND_NODE_FOR_MINFEE && config.MEMPOOL.LIMIT_GBT;
|
||||
|
||||
constructor() {
|
||||
setInterval(this.updateTxPerSecond.bind(this), 1000);
|
||||
// Initialize mempoolInfo here to avoid circular dependency issues
|
||||
// Use config directly instead of Common.isLiquid() to break circular dependency
|
||||
const isLiquid = config.MEMPOOL.NETWORK === 'liquid' || config.MEMPOOL.NETWORK === 'liquidtestnet';
|
||||
this.mempoolInfo = {
|
||||
loaded: false,
|
||||
size: 0,
|
||||
bytes: 0,
|
||||
usage: 0,
|
||||
total_fee: 0,
|
||||
maxmempool: 300000000,
|
||||
mempoolminfee: isLiquid ? 0.00000100 : 0.00001000,
|
||||
minrelaytxfee: isLiquid ? 0.00000100 : 0.00001000
|
||||
};
|
||||
this.txPerSecondInterval = setInterval(this.updateTxPerSecond.bind(this), 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources (timers, etc.)
|
||||
* This should only be called when shutting down or in test teardown
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.txPerSecondInterval) {
|
||||
clearInterval(this.txPerSecondInterval);
|
||||
this.txPerSecondInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -411,7 +435,7 @@ class Mempool {
|
|||
}
|
||||
}
|
||||
|
||||
public async getNextCandidates(minFeeTransactions: string[], blockHeight: number, deletedTransactions: MempoolTransactionExtended[]): Promise<GbtCandidates | undefined> {
|
||||
public getNextCandidates(minFeeTransactions: string[], blockHeight: number, deletedTransactions: MempoolTransactionExtended[]): GbtCandidates | undefined {
|
||||
if (this.limitGBT) {
|
||||
const deletedTxsMap = {};
|
||||
for (const tx of deletedTransactions) {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class MiningRoutes {
|
|||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
if (['testnet', 'signet', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (['testnet', 'signet', 'liquidtestnet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
handleError(req, res, 400, 'Prices are not available on testnets.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -394,7 +394,7 @@ class MiningRoutes {
|
|||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
handleError(req, res, 400, 'Acceleration data is not available.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -409,7 +409,7 @@ class MiningRoutes {
|
|||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
handleError(req, res, 400, 'Acceleration data is not available.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -425,7 +425,7 @@ class MiningRoutes {
|
|||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
handleError(req, res, 400, 'Acceleration data is not available.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -440,7 +440,7 @@ class MiningRoutes {
|
|||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
handleError(req, res, 400, 'Acceleration data is not available.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -455,7 +455,7 @@ class MiningRoutes {
|
|||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
handleError(req, res, 400, 'Acceleration data is not available.');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ class Mining {
|
|||
public reindexHashrateRequested = false;
|
||||
public reindexDifficultyAdjustmentRequested = false;
|
||||
|
||||
private genesisData: {
|
||||
timestamp: number,
|
||||
bits: number,
|
||||
difficulty: number,
|
||||
} | null = null;
|
||||
|
||||
/**
|
||||
* Get historical blocks health
|
||||
*/
|
||||
|
|
@ -224,8 +230,8 @@ class Mining {
|
|||
try {
|
||||
const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
|
||||
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0));
|
||||
const genesisTimestamp = genesisBlock.timestamp * 1000;
|
||||
const genesisData = await this.getGenesisData();
|
||||
const genesisTimestamp = genesisData.timestamp * 1000;
|
||||
|
||||
const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
|
||||
const hashrates: any[] = [];
|
||||
|
|
@ -340,8 +346,8 @@ class Mining {
|
|||
const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
|
||||
|
||||
try {
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0));
|
||||
const genesisTimestamp = genesisBlock.timestamp * 1000;
|
||||
const genesisData = await this.getGenesisData();
|
||||
const genesisTimestamp = genesisData.timestamp * 1000;
|
||||
const indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp);
|
||||
const lastMidnight = this.getDateMidnight(new Date());
|
||||
let toTimestamp = Math.round(lastMidnight.getTime());
|
||||
|
|
@ -450,14 +456,14 @@ class Mining {
|
|||
|
||||
// gets {time, height, difficulty, bits} of blocks in ascending order of height
|
||||
const blocks: any = await BlocksRepository.$getBlocksDifficulty();
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0));
|
||||
let currentDifficulty = genesisBlock.difficulty;
|
||||
let currentBits = genesisBlock.bits;
|
||||
const genesisData = await this.getGenesisData();
|
||||
let currentDifficulty = genesisData.difficulty;
|
||||
let currentBits = genesisData.bits;
|
||||
let totalIndexed = 0;
|
||||
|
||||
if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) {
|
||||
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
||||
time: genesisBlock.timestamp,
|
||||
time: genesisData.timestamp,
|
||||
height: 0,
|
||||
difficulty: currentDifficulty,
|
||||
adjustment: 0.0,
|
||||
|
|
@ -694,6 +700,18 @@ class Mining {
|
|||
}
|
||||
return blocks[0];
|
||||
}
|
||||
|
||||
private async getGenesisData(): Promise<{timestamp: number, bits: number, difficulty: number}> {
|
||||
if (this.genesisData == null) {
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0));
|
||||
this.genesisData = {
|
||||
timestamp: genesisBlock.timestamp,
|
||||
bits: genesisBlock.bits,
|
||||
difficulty: genesisBlock.difficulty,
|
||||
};
|
||||
}
|
||||
return this.genesisData;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Mining();
|
||||
|
|
|
|||
|
|
@ -122,10 +122,8 @@ class PoolsParser {
|
|||
// refresh the in-memory block cache with the reindexed data
|
||||
if (clearCache) {
|
||||
for (const block of blocks.getBlocks()) {
|
||||
const reindexedBlock = await blocks.$indexBlock(block.height);
|
||||
if (reindexedBlock.id === block.id) {
|
||||
block.extras.pool = reindexedBlock.extras.pool;
|
||||
}
|
||||
const reindexedBlock = await blocks.$indexBlock(block.id);
|
||||
block.extras.pool = reindexedBlock.extras.pool;
|
||||
}
|
||||
// update persistent cache with the reindexed data
|
||||
diskCache.$saveCacheToDisk();
|
||||
|
|
@ -197,7 +195,7 @@ class PoolsParser {
|
|||
let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52
|
||||
if (config.MEMPOOL.NETWORK === 'testnet') {
|
||||
firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581
|
||||
} else if (config.MEMPOOL.NETWORK === 'signet') {
|
||||
} else if (['signet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
firstKnownBlockPool = 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { Application, Request, Response } from 'express';
|
||||
import config from '../../config';
|
||||
import pricesUpdater from '../../tasks/price-updater';
|
||||
import logger from '../../logger';
|
||||
import PricesRepository from '../../repositories/PricesRepository';
|
||||
|
||||
class PricesRoutes {
|
||||
public initRoutes(app: Application): void {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'prices', this.$getCurrentPrices.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'internal/usd-price-history', this.$getAllPrices.bind(this))
|
||||
;
|
||||
}
|
||||
|
||||
|
|
@ -19,23 +16,6 @@ class PricesRoutes {
|
|||
|
||||
res.json(pricesUpdater.getLatestPrices());
|
||||
}
|
||||
|
||||
private async $getAllPrices(req: Request, res: Response): Promise<void> {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 360_0000 / config.MEMPOOL.PRICE_UPDATES_PER_HOUR).toUTCString());
|
||||
|
||||
try {
|
||||
const usdPriceHistory = await PricesRepository.$getPricesTimesAndId();
|
||||
const responseData = usdPriceHistory.map(p => {
|
||||
return { time: p.time, USD: p.USD };
|
||||
});
|
||||
res.status(200).json(responseData);
|
||||
} catch (e: any) {
|
||||
logger.err(`Exception ${e} in PricesRoutes::$getAllPrices. Code: ${e.code}. Message: ${e.message}`);
|
||||
res.status(403).send();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new PricesRoutes();
|
||||
|
|
|
|||
|
|
@ -484,7 +484,7 @@ class RbfCache {
|
|||
return deflated;
|
||||
}
|
||||
|
||||
async importTree(mempool, root, txid, deflated, txs: Map<string, MempoolTransactionExtended>, mined: boolean = false): Promise<RbfTree | void> {
|
||||
importTree(mempool, root, txid, deflated, txs: Map<string, MempoolTransactionExtended>, mined: boolean = false): RbfTree | void {
|
||||
const treeInfo = deflated[txid];
|
||||
const replaces: RbfTree[] = [];
|
||||
|
||||
|
|
@ -503,7 +503,7 @@ class RbfCache {
|
|||
|
||||
// recursively reconstruct child trees
|
||||
for (const childId of treeInfo.replaces) {
|
||||
const replaced = await this.importTree(mempool, root, childId, deflated, txs, mined);
|
||||
const replaced = this.importTree(mempool, root, childId, deflated, txs, mined);
|
||||
if (replaced) {
|
||||
this.replacedBy.set(replaced.tx.txid, txid);
|
||||
if (mempool[replaced.tx.txid]) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ interface Treasury {
|
|||
name: string,
|
||||
wallet: string,
|
||||
enterprise: string,
|
||||
verifiedAddresses: string[],
|
||||
balances: { balance: number, time: number }[], // off-chain balances
|
||||
}
|
||||
|
||||
const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes
|
||||
|
|
@ -196,6 +198,15 @@ class WalletApi {
|
|||
} catch (e) {
|
||||
logger.err(`Error updating active treasuries: ${(e instanceof Error ? e.message : e)}`);
|
||||
}
|
||||
|
||||
// insert dummy address data to represent off-chain balance history
|
||||
for (const treasury of this.treasuries) {
|
||||
if (treasury.balances?.length) {
|
||||
if (this.wallets[treasury.wallet]) {
|
||||
this.wallets[treasury.wallet].addresses['private'] = convertBalancesToWalletAddress(treasury.wallet, treasury.balances);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const walletKey of Object.keys(this.wallets)) {
|
||||
|
|
@ -211,7 +222,7 @@ class WalletApi {
|
|||
}
|
||||
// remove old addresses
|
||||
for (const address of Object.keys(wallet.addresses)) {
|
||||
if (!addresses[address]) {
|
||||
if (address !== 'private' && !addresses[address]) {
|
||||
delete wallet.addresses[address];
|
||||
}
|
||||
}
|
||||
|
|
@ -304,4 +315,34 @@ class WalletApi {
|
|||
}
|
||||
}
|
||||
|
||||
function convertBalancesToWalletAddress(wallet: string, balances: { balance: number, time: number }[]): WalletAddress {
|
||||
// represent the off-chain balance as a series of transactions modifying a single notional UTXO
|
||||
const sortedBalances = balances.sort((a, b) => a.time - b.time);
|
||||
const walletAddress: WalletAddress = {
|
||||
address: 'private',
|
||||
active: false,
|
||||
stats: {
|
||||
funded_txo_count: 0,
|
||||
funded_txo_sum: sortedBalances[sortedBalances.length - 1].balance,
|
||||
spent_txo_count: 0,
|
||||
spent_txo_sum: 0,
|
||||
tx_count: 0,
|
||||
},
|
||||
transactions: [],
|
||||
lastSync: sortedBalances[sortedBalances.length - 1].time,
|
||||
};
|
||||
let lastBalance = 0;
|
||||
for (const [index, entry] of sortedBalances.entries()) {
|
||||
const diff = entry.balance - lastBalance;
|
||||
walletAddress.transactions.push({
|
||||
txid: `${wallet}-private-${index}`,
|
||||
value: diff,
|
||||
height: index,
|
||||
time: entry.time,
|
||||
});
|
||||
lastBalance = entry.balance;
|
||||
}
|
||||
return walletAddress;
|
||||
}
|
||||
|
||||
export default new WalletApi();
|
||||
|
|
@ -145,6 +145,9 @@ class TransactionUtils {
|
|||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the witness-adjusted sigops cost of an asm script
|
||||
*/
|
||||
public countScriptSigops(script: string, isRawScript: boolean = false, witness: boolean = false): number {
|
||||
if (!script?.length) {
|
||||
return 0;
|
||||
|
|
@ -213,6 +216,41 @@ class TransactionUtils {
|
|||
return sigops;
|
||||
}
|
||||
|
||||
/**
|
||||
* see https://github.com/bitcoin/bitcoin/blob/25c45bb0d0bd6618ec9296a1a43605657124e5de/src/policy/policy.cpp#L166-L193
|
||||
* returns true if the transactions is permitted under bip54 sigops rules
|
||||
*
|
||||
* "Unlike the existing block wide sigop limit which counts sigops present in the block
|
||||
* itself (including the scriptPubKey which is not executed until spending later), BIP54
|
||||
* counts sigops in the block where they are potentially executed (only).
|
||||
* This means sigops in the spent scriptPubKey count toward the limit.
|
||||
* `fAccurate` means correctly accounting sigops for CHECKMULTISIGs(VERIFY) with 16 pubkeys
|
||||
* or fewer. This method of accounting was introduced by BIP16, and BIP54 reuses it.
|
||||
* The GetSigOpCount call on the previous scriptPubKey counts both bare and P2SH sigops."
|
||||
*/
|
||||
public checkSigopsBIP54(tx: TransactionExtended, limit): boolean {
|
||||
let sigops = 0;
|
||||
for (const input of tx.vin) {
|
||||
if (input.scriptsig_asm) {
|
||||
sigops += this.countScriptSigops(input.scriptsig_asm);
|
||||
}
|
||||
if (input.prevout) {
|
||||
// P2SH redeem script
|
||||
if (input.prevout.scriptpubkey_type === 'p2sh' && input.inner_redeemscript_asm) {
|
||||
sigops += this.countScriptSigops(input.inner_redeemscript_asm);
|
||||
} else {
|
||||
// prevout scriptpubkey
|
||||
sigops += this.countScriptSigops(input.prevout.scriptpubkey_asm);
|
||||
}
|
||||
}
|
||||
|
||||
if (sigops > limit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// returns the most significant 4 bytes of the txid as an integer
|
||||
public txidToOrdering(txid: string): number {
|
||||
return parseInt(
|
||||
|
|
@ -229,7 +267,7 @@ class TransactionUtils {
|
|||
return;
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'p2sh') {
|
||||
if (vin.prevout.scriptpubkey_type === 'p2sh' && vin.scriptsig_asm?.length) {
|
||||
const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
|
||||
vin.inner_redeemscript_asm = this.convertScriptSigAsm(redeemScript);
|
||||
if (vin.witness && vin.witness.length > 2) {
|
||||
|
|
@ -262,15 +300,15 @@ class TransactionUtils {
|
|||
if (op >= 0x01 && op <= 0x4e) {
|
||||
i++;
|
||||
let push: number;
|
||||
if (op === 0x4c) {
|
||||
if (op === 0x4c && buf.length > i) {
|
||||
push = buf.readUInt8(i);
|
||||
b.push('OP_PUSHDATA1');
|
||||
i += 1;
|
||||
} else if (op === 0x4d) {
|
||||
} else if (op === 0x4d && buf.length > i + 1) {
|
||||
push = buf.readUInt16LE(i);
|
||||
b.push('OP_PUSHDATA2');
|
||||
i += 2;
|
||||
} else if (op === 0x4e) {
|
||||
} else if (op === 0x4e && buf.length > i + 3) {
|
||||
push = buf.readUInt32LE(i);
|
||||
b.push('OP_PUSHDATA4');
|
||||
i += 4;
|
||||
|
|
@ -279,13 +317,15 @@ class TransactionUtils {
|
|||
b.push('OP_PUSHBYTES_' + push);
|
||||
}
|
||||
|
||||
const data = buf.slice(i, i + push);
|
||||
if (i >= buf.length) {
|
||||
break;
|
||||
}
|
||||
const data = buf.subarray(i, Math.min(i + push, buf.length));
|
||||
b.push(data.toString('hex'));
|
||||
i += data.length;
|
||||
if (data.length !== push) {
|
||||
break;
|
||||
}
|
||||
|
||||
b.push(data.toString('hex'));
|
||||
i += data.length;
|
||||
} else {
|
||||
if (op === 0x00) {
|
||||
b.push('OP_0');
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ class WebsocketHandler {
|
|||
'backendInfo': backendInfo.getBackendInfo(),
|
||||
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
|
||||
'da': da?.previousTime ? da : undefined,
|
||||
'fees': feeApi.getRecommendedFee(),
|
||||
'fees': feeApi.getPreciseRecommendedFee(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -639,7 +639,7 @@ class WebsocketHandler {
|
|||
}
|
||||
memPool.removeFromSpendMap(deletedTransactions);
|
||||
memPool.addToSpendMap(newTransactions);
|
||||
const recommendedFees = feeApi.getRecommendedFee();
|
||||
const recommendedFees = feeApi.getPreciseRecommendedFee();
|
||||
|
||||
const latestTransactions = memPool.getLatestTransactions();
|
||||
|
||||
|
|
@ -1016,7 +1016,7 @@ class WebsocketHandler {
|
|||
}
|
||||
|
||||
const _memPool = memPool.getMempool();
|
||||
const candidateTxs = await memPool.getMempoolCandidates();
|
||||
const candidateTxs = memPool.getMempoolCandidates();
|
||||
let candidates: GbtCandidates | undefined = (memPool.limitGBT && candidateTxs) ? { txs: candidateTxs, added: [], removed: [] } : undefined;
|
||||
let transactionIds: string[] = (memPool.limitGBT) ? Object.keys(candidates?.txs || {}) : Object.keys(_memPool);
|
||||
|
||||
|
|
@ -1118,7 +1118,7 @@ class WebsocketHandler {
|
|||
if (memPool.limitGBT) {
|
||||
const minFeeMempool = memPool.limitGBT ? await bitcoinSecondClient.getRawMemPool() : null;
|
||||
const minFeeTip = memPool.limitGBT ? await bitcoinSecondClient.getBlockCount() : -1;
|
||||
candidates = await memPool.getNextCandidates(minFeeMempool, minFeeTip, transactions);
|
||||
candidates = memPool.getNextCandidates(minFeeMempool, minFeeTip, transactions);
|
||||
transactionIds = Object.keys(candidates?.txs || {});
|
||||
} else {
|
||||
candidates = undefined;
|
||||
|
|
@ -1137,7 +1137,7 @@ class WebsocketHandler {
|
|||
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
||||
|
||||
const da = difficultyAdjustment.getDifficultyAdjustment();
|
||||
const fees = feeApi.getRecommendedFee();
|
||||
const fees = feeApi.getPreciseRecommendedFee();
|
||||
const mempoolInfo = memPool.getMempoolInfo();
|
||||
|
||||
// pre-compute address transactions
|
||||
|
|
|
|||
|
|
@ -89,8 +89,9 @@ import { execSync } from 'child_process';
|
|||
OkPacket[] | ResultSetHeader>(queries: { query, params }[], errorLogLevel: LogLevel | 'silent' = 'debug'): Promise<[T, FieldPacket[]][]>
|
||||
{
|
||||
const pool = await this.getPool();
|
||||
const connection = await pool.getConnection();
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
const results: [T, FieldPacket[]][] = [];
|
||||
|
|
@ -104,10 +105,14 @@ import { execSync } from 'child_process';
|
|||
return results;
|
||||
} catch (e) {
|
||||
logger.warn('Could not complete db transaction, rolling back: ' + (e instanceof Error ? e.message : e));
|
||||
this.$rollbackAtomic(connection);
|
||||
if (connection) {
|
||||
await this.$rollbackAtomic(connection);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
connection.release();
|
||||
if (connection) {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +180,19 @@ import { execSync } from 'child_process';
|
|||
}
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection pool
|
||||
* This should only be called when the application is shutting down
|
||||
* or at the end of test suites
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (this.pool !== null) {
|
||||
await this.pool.end();
|
||||
this.pool = null;
|
||||
logger.debug('Database connection pool closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new DB();
|
||||
|
|
|
|||
|
|
@ -100,14 +100,20 @@ class Server {
|
|||
logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`);
|
||||
|
||||
// Register cleanup listeners for exit events
|
||||
['exit', 'SIGHUP', 'SIGINT', 'SIGTERM', 'SIGUSR1', 'SIGUSR2'].forEach(event => {
|
||||
process.on(event, () => { this.onExit(event); });
|
||||
['SIGHUP', 'SIGINT', 'SIGTERM', 'SIGUSR1', 'SIGUSR2'].forEach(event => {
|
||||
process.on(event, () => { this.forceExit(event); });
|
||||
});
|
||||
process.on('exit', () => {
|
||||
logger.debug(`'exit' event triggered`);
|
||||
this.exitCleanup();
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
this.onUnhandledException('uncaughtException', error);
|
||||
console.error(`uncaughtException:`, error);
|
||||
this.forceExit('uncaughtException', 1);
|
||||
});
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
this.onUnhandledException('unhandledRejection', reason);
|
||||
console.error(`unhandledRejection:`, reason, promise);
|
||||
this.forceExit('unhandledRejection', 1);
|
||||
});
|
||||
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
|
|
@ -136,9 +142,9 @@ class Server {
|
|||
res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count,X-Mempool-Auth');
|
||||
next();
|
||||
})
|
||||
.use(express.urlencoded({ extended: true }))
|
||||
.use(express.text({ type: ['text/plain', 'application/base64'] }))
|
||||
.use(express.json())
|
||||
.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
||||
.use(express.text({ type: ['text/plain', 'application/base64'], limit: '10mb' }))
|
||||
.use(express.json({ limit: '10mb' }))
|
||||
;
|
||||
|
||||
if (config.DATABASE.ENABLED && config.FIAT_PRICE.ENABLED) {
|
||||
|
|
@ -155,7 +161,7 @@ class Server {
|
|||
this.setUpWebsocketHandling();
|
||||
|
||||
await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it
|
||||
if (config.DATABASE.ENABLED === true && config.MEMPOOL.ENABLED && ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && !poolsUpdater.currentSha) {
|
||||
if (config.DATABASE.ENABLED === true && config.MEMPOOL.ENABLED && ['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK) && !poolsUpdater.currentSha) {
|
||||
logger.err(`Failed to retreive pools-v2.json sha, cannot run block indexing. Please make sure you've set valid urls in your mempool-config.json::MEMPOOL::POOLS_JSON_URL and mempool-config.json::MEMPOOL::POOLS_JSON_TREE_UR, aborting now`);
|
||||
return process.exit(1);
|
||||
}
|
||||
|
|
@ -387,8 +393,16 @@ class Server {
|
|||
}
|
||||
}
|
||||
|
||||
onExit(exitEvent, code = 0): void {
|
||||
logger.debug(`onExit for signal: ${exitEvent}`);
|
||||
forceExit(exitEvent, code?: number): void {
|
||||
logger.debug(`triggering exit for signal: ${exitEvent}`);
|
||||
if (code != null) {
|
||||
// override the default exit code
|
||||
process.exitCode = code;
|
||||
}
|
||||
process.exit();
|
||||
}
|
||||
|
||||
exitCleanup(): void {
|
||||
if (config.DATABASE.ENABLED) {
|
||||
DB.releasePidLock();
|
||||
}
|
||||
|
|
@ -398,12 +412,6 @@ class Server {
|
|||
if (this.wssUnixSocket) {
|
||||
this.wssUnixSocket.close();
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
onUnhandledException(type, error): void {
|
||||
console.error(`${type}:`, error);
|
||||
this.onExit(type, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class Indexer {
|
|||
private indexerRunning = false;
|
||||
private tasksRunning: { [key in TaskName]?: boolean; } = {};
|
||||
private tasksScheduled: { [key in TaskName]?: NodeJS.Timeout; } = {};
|
||||
private reindexTimeout: NodeJS.Timeout | undefined;
|
||||
private coreIndexes: CoreIndex[] = [];
|
||||
|
||||
public indexerIsRunning(): boolean {
|
||||
|
|
@ -76,10 +77,23 @@ class Indexer {
|
|||
|
||||
public reindex(): void {
|
||||
if (Common.indexingEnabled()) {
|
||||
if (this.reindexTimeout) {
|
||||
clearTimeout(this.reindexTimeout);
|
||||
this.reindexTimeout = undefined;
|
||||
}
|
||||
this.runIndexer = true;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNextRun(timeout: number): void {
|
||||
if (!this.reindexTimeout) { // Only one future run should be planned, ignore if already scheduled
|
||||
this.reindexTimeout = setTimeout(() => {
|
||||
this.reindexTimeout = undefined;
|
||||
this.reindex();
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* schedules a single task to run in `timeout` ms
|
||||
* only one task of each type may be scheduled
|
||||
|
|
@ -120,7 +134,7 @@ class Indexer {
|
|||
|
||||
switch (task) {
|
||||
case 'blocksPrices': {
|
||||
if (!['testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && config.FIAT_PRICE.ENABLED) {
|
||||
if (!['testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK) && config.FIAT_PRICE.ENABLED) {
|
||||
let lastestPriceId;
|
||||
try {
|
||||
lastestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
|
|
@ -138,7 +152,11 @@ class Indexer {
|
|||
|
||||
case 'coinStatsIndex': {
|
||||
logger.debug(`Indexing coinStatsIndex now`);
|
||||
await mining.$indexCoinStatsIndex();
|
||||
try {
|
||||
await mining.$indexCoinStatsIndex();
|
||||
} catch (e) {
|
||||
logger.debug(`failed to index coinstatsindex: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
} break;
|
||||
}
|
||||
|
||||
|
|
@ -152,34 +170,40 @@ class Indexer {
|
|||
return;
|
||||
}
|
||||
|
||||
if (config.FIAT_PRICE.ENABLED) {
|
||||
try {
|
||||
await priceUpdater.$run();
|
||||
} catch (e) {
|
||||
logger.err(`Running priceUpdater failed. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
// Do not attempt to index anything unless Bitcoin Core is fully synced
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
if (blockchainInfo.blocks !== blockchainInfo.headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runIndexer = false;
|
||||
this.indexerRunning = true;
|
||||
|
||||
logger.debug(`Running mining indexer`);
|
||||
|
||||
await this.checkAvailableCoreIndexes();
|
||||
const retryDelay = 10000;
|
||||
const runEvery = 1000 * 3600; // 1 hour
|
||||
let nextRunDelay = runEvery;
|
||||
let runSuccessful = false;
|
||||
|
||||
try {
|
||||
if (config.FIAT_PRICE.ENABLED) {
|
||||
try {
|
||||
await priceUpdater.$run();
|
||||
} catch (e) {
|
||||
logger.err(`Running priceUpdater failed. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
// Do not attempt to index anything unless Bitcoin Core is fully synced
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
if (blockchainInfo.blocks !== blockchainInfo.headers) {
|
||||
logger.debug(`Bitcoin Core not fully synced, retrying index run in 10 seconds.`);
|
||||
nextRunDelay = retryDelay;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Running mining indexer`);
|
||||
|
||||
await this.checkAvailableCoreIndexes();
|
||||
|
||||
const chainValid = await blocks.$generateBlockDatabase();
|
||||
if (chainValid === false) {
|
||||
// Chain of block hash was invalid, so we need to reindex. Stop here and continue at the next iteration
|
||||
logger.warn(`The chain of block hash is invalid, re-indexing invalid data in 10 seconds.`, logger.tags.mining);
|
||||
setTimeout(() => this.reindex(), 10000);
|
||||
this.indexerRunning = false;
|
||||
nextRunDelay = retryDelay;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -198,19 +222,20 @@ class Indexer {
|
|||
await BlocksRepository.$migrateBlocks();
|
||||
// do not wait for classify blocks to finish
|
||||
blocks.$classifyBlocks();
|
||||
runSuccessful = true;
|
||||
} catch (e) {
|
||||
this.indexerRunning = false;
|
||||
nextRunDelay = retryDelay;
|
||||
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
setTimeout(() => this.reindex(), 10000);
|
||||
} finally {
|
||||
this.indexerRunning = false;
|
||||
return;
|
||||
const nextRunAt = new Date(Date.now() + nextRunDelay).toUTCString();
|
||||
if (runSuccessful) {
|
||||
logger.debug(`Indexing completed. Next run planned at ${nextRunAt}`);
|
||||
} else {
|
||||
logger.debug(`Indexing did not complete, next run planned at ${nextRunAt}`);
|
||||
}
|
||||
this.scheduleNextRun(nextRunDelay);
|
||||
}
|
||||
|
||||
this.indexerRunning = false;
|
||||
|
||||
const runEvery = 1000 * 3600; // 1 hour
|
||||
logger.debug(`Indexing completed. Next run planned at ${new Date(new Date().getTime() + runEvery).toUTCString()}`);
|
||||
setTimeout(() => this.reindex(), runEvery);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ class Logger {
|
|||
}
|
||||
}
|
||||
this.client = dgram.createSocket('udp4');
|
||||
// Unref the socket so it doesn't prevent Node.js from exiting
|
||||
this.client.unref();
|
||||
this.network = this.getNetwork();
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +155,20 @@ class Logger {
|
|||
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return months[month] + ' ' + day + ' ' + hours + ':' + minutes + ':' + seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the UDP socket used for syslog
|
||||
* This should only be called when shutting down or in test teardown
|
||||
*/
|
||||
public close(): void {
|
||||
if (this.client) {
|
||||
// Unref allows Node.js to exit even if the socket is open
|
||||
this.client.unref();
|
||||
this.client.close(() => {
|
||||
// Socket closed callback
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type LogLevel = 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug';
|
||||
|
|
|
|||
|
|
@ -504,9 +504,35 @@ export interface IBackendInfo {
|
|||
gitCommit: string;
|
||||
version: string;
|
||||
lightning: boolean;
|
||||
coreVersion: string;
|
||||
backend: 'esplora' | 'electrum' | 'none';
|
||||
}
|
||||
|
||||
export interface INetworkInfo {
|
||||
version: number;
|
||||
subversion: string;
|
||||
protocolversion: number;
|
||||
localservices: string;
|
||||
localrelay: boolean;
|
||||
timeoffset: number;
|
||||
networkactive: boolean;
|
||||
networks: {
|
||||
name: string;
|
||||
limited: boolean;
|
||||
reachable: boolean;
|
||||
proxy: string;
|
||||
proxy_randomize_credentials: boolean;
|
||||
}[];
|
||||
relayfee: number;
|
||||
incrementalfee: number;
|
||||
localaddresses: {
|
||||
address: string;
|
||||
port: number;
|
||||
score: number;
|
||||
}[];
|
||||
warnings: string;
|
||||
}
|
||||
|
||||
export interface IDifficultyAdjustment {
|
||||
progressPercent: number;
|
||||
difficultyChange: number;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,32 @@ class AccelerationRepository {
|
|||
}
|
||||
}
|
||||
|
||||
public async $getAccelerationInfoForTxid(txid: string): Promise<PublicAcceleration | null> {
|
||||
const [rows] = await DB.query(`
|
||||
SELECT *, UNIX_TIMESTAMP(requested) as requested_timestamp, UNIX_TIMESTAMP(added) as block_timestamp FROM accelerations
|
||||
JOIN pools on pools.unique_id = accelerations.pool
|
||||
WHERE txid = ?
|
||||
`, [txid]) as RowDataPacket[][];
|
||||
if (rows?.length) {
|
||||
const row = rows[0];
|
||||
return {
|
||||
txid: row.txid,
|
||||
height: row.height,
|
||||
added: row.requested_timestamp || row.block_timestamp,
|
||||
pool: {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
},
|
||||
effective_vsize: row.effective_vsize,
|
||||
effective_fee: row.effective_fee,
|
||||
boost_rate: row.boost_rate,
|
||||
boost_cost: row.boost_cost,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async $getAccelerationInfo(poolSlug: string | null = null, height: number | null = null, interval: string | null = null): Promise<PublicAcceleration[]> {
|
||||
if (!interval || !['24h', '3d', '1w', '1m'].includes(interval)) {
|
||||
interval = '1m';
|
||||
|
|
@ -256,7 +282,7 @@ class AccelerationRepository {
|
|||
try {
|
||||
while (!done) {
|
||||
// don't DDoS the services backend
|
||||
Common.sleep$(500 + (Math.random() * 1000));
|
||||
await Common.sleep$(500 + (Math.random() * 1000));
|
||||
const accelerations = await accelerationApi.$fetchAccelerationHistory(page);
|
||||
page++;
|
||||
if (!accelerations?.length) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import bitcoinApi from '../api/bitcoin/bitcoin-api-factory';
|
||||
import bitcoinApi, { bitcoinCoreApi } from '../api/bitcoin/bitcoin-api-factory';
|
||||
import { BlockExtended, BlockExtension, BlockPrice, EffectiveFeeStats } from '../mempool.interfaces';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
|
|
@ -60,6 +60,7 @@ interface DatabaseBlock {
|
|||
utxoSetSize: number;
|
||||
totalInputAmt: number;
|
||||
firstSeen: number;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
const BLOCK_DB_FIELDS = `
|
||||
|
|
@ -104,7 +105,8 @@ const BLOCK_DB_FIELDS = `
|
|||
blocks.utxoset_change AS utxoSetChange,
|
||||
blocks.utxoset_size AS utxoSetSize,
|
||||
blocks.total_input_amt AS totalInputAmt,
|
||||
UNIX_TIMESTAMP(blocks.first_seen) AS firstSeen
|
||||
UNIX_TIMESTAMP(blocks.first_seen) AS firstSeen,
|
||||
blocks.stale
|
||||
`;
|
||||
|
||||
class BlocksRepository {
|
||||
|
|
@ -128,7 +130,8 @@ class BlocksRepository {
|
|||
coinbase_signature, utxoset_size, utxoset_change, avg_tx_size,
|
||||
total_inputs, total_outputs, total_input_amt, total_output_amt,
|
||||
fee_percentiles, segwit_total_txs, segwit_total_size, segwit_total_weight,
|
||||
median_fee_amt, coinbase_signature_ascii, definition_hash, index_version
|
||||
median_fee_amt, coinbase_signature_ascii, definition_hash, index_version,
|
||||
stale
|
||||
) VALUE (
|
||||
?, ?, FROM_UNIXTIME(?), ?,
|
||||
?, ?, ?, ?,
|
||||
|
|
@ -139,7 +142,8 @@ class BlocksRepository {
|
|||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
?, ?, ?, ?,
|
||||
?
|
||||
)`;
|
||||
|
||||
const poolDbId = await PoolsRepository.$getPoolByUniqueId(block.extras.pool.id);
|
||||
|
|
@ -187,13 +191,23 @@ class BlocksRepository {
|
|||
block.extras.medianFeeAmt,
|
||||
truncatedCoinbaseSignatureAscii,
|
||||
poolsUpdater.currentSha,
|
||||
BlocksRepository.version
|
||||
BlocksRepository.version,
|
||||
(block.stale ? 1 : 0),
|
||||
];
|
||||
|
||||
await DB.query(query, params);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`$saveBlockInDatabase() - Block ${block.height} has already been indexed, ignoring`, logger.tags.mining);
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart or if a stale block is reconnected
|
||||
if (!block.stale) {
|
||||
logger.debug(`$saveBlockInDatabase() - Block ${block.height} has already been indexed, setting as canonical`, logger.tags.mining);
|
||||
try {
|
||||
await this.$setCanonicalBlockAtHeight(block.id, block.height);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot set canonical block at height ${block.height}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
} else {
|
||||
logger.debug(`$saveBlockInDatabase() - Block ${block.height} has already been indexed, ignoring`, logger.tags.mining);
|
||||
}
|
||||
} else {
|
||||
logger.err('Cannot save indexed block into db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
|
|
@ -258,7 +272,11 @@ class BlocksRepository {
|
|||
* Get all block height that have not been indexed between [startHeight, endHeight]
|
||||
*/
|
||||
public async $getMissingBlocksBetweenHeights(startHeight: number, endHeight: number): Promise<number[]> {
|
||||
if (startHeight < endHeight) {
|
||||
// Ensure startHeight is the lower value and endHeight is the higher value
|
||||
const minHeight = Math.min(startHeight, endHeight);
|
||||
const maxHeight = Math.max(startHeight, endHeight);
|
||||
|
||||
if (minHeight === maxHeight) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -266,13 +284,13 @@ class BlocksRepository {
|
|||
const [rows]: any[] = await DB.query(`
|
||||
SELECT height
|
||||
FROM blocks
|
||||
WHERE height <= ? AND height >= ?
|
||||
ORDER BY height DESC;
|
||||
`, [startHeight, endHeight]);
|
||||
WHERE height >= ? AND height <= ? AND stale = 0
|
||||
ORDER BY height ASC;
|
||||
`, [minHeight, maxHeight]);
|
||||
|
||||
const indexedBlockHeights: number[] = [];
|
||||
rows.forEach((row: any) => { indexedBlockHeights.push(row.height); });
|
||||
const seekedBlocks: number[] = Array.from(Array(startHeight - endHeight + 1).keys(), n => n + endHeight).reverse();
|
||||
const seekedBlocks: number[] = Array.from(Array(maxHeight - minHeight + 1).keys(), n => n + minHeight);
|
||||
const missingBlocksHeights = seekedBlocks.filter(x => indexedBlockHeights.indexOf(x) === -1);
|
||||
|
||||
return missingBlocksHeights;
|
||||
|
|
@ -292,7 +310,7 @@ class BlocksRepository {
|
|||
let query = `SELECT count(height) as count, pools.id as poolId
|
||||
FROM blocks
|
||||
JOIN pools on pools.id = blocks.pool_id
|
||||
WHERE tx_count = 1`;
|
||||
WHERE tx_count = 1 AND stale = 0`;
|
||||
|
||||
if (poolId) {
|
||||
query += ` AND pool_id = ?`;
|
||||
|
|
@ -335,20 +353,16 @@ class BlocksRepository {
|
|||
|
||||
const params: any[] = [];
|
||||
let query = `SELECT count(height) as blockCount
|
||||
FROM blocks`;
|
||||
FROM blocks
|
||||
WHERE stale = 0`;
|
||||
|
||||
if (poolId) {
|
||||
query += ` WHERE pool_id = ?`;
|
||||
query += ` AND pool_id = ?`;
|
||||
params.push(poolId);
|
||||
}
|
||||
|
||||
if (interval) {
|
||||
if (poolId) {
|
||||
query += ` AND`;
|
||||
} else {
|
||||
query += ` WHERE`;
|
||||
}
|
||||
query += ` blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -372,19 +386,15 @@ class BlocksRepository {
|
|||
let query = `SELECT
|
||||
count(height) as blockCount,
|
||||
max(height) as lastBlockHeight
|
||||
FROM blocks`;
|
||||
FROM blocks
|
||||
WHERE stale = 0`;
|
||||
|
||||
if (poolId) {
|
||||
query += ` WHERE pool_id = ?`;
|
||||
query += ` AND pool_id = ?`;
|
||||
params.push(poolId);
|
||||
}
|
||||
|
||||
if (poolId) {
|
||||
query += ` AND`;
|
||||
} else {
|
||||
query += ` WHERE`;
|
||||
}
|
||||
query += ` blockTimestamp BETWEEN FROM_UNIXTIME('${from}') AND FROM_UNIXTIME('${to}')`;
|
||||
query += ` AND blockTimestamp BETWEEN FROM_UNIXTIME('${from}') AND FROM_UNIXTIME('${to}')`;
|
||||
|
||||
try {
|
||||
const [rows] = await DB.query(query, params);
|
||||
|
|
@ -402,7 +412,7 @@ class BlocksRepository {
|
|||
const params: any[] = [];
|
||||
let query = `SELECT count(height) as blockCount
|
||||
FROM blocks
|
||||
WHERE height <= ${startHeight} AND height >= ${endHeight}`;
|
||||
WHERE height <= ${startHeight} AND height >= ${endHeight} AND stale = 0`;
|
||||
|
||||
try {
|
||||
const [rows] = await DB.query(query, params);
|
||||
|
|
@ -422,7 +432,7 @@ class BlocksRepository {
|
|||
SELECT AVG(blocks_audits.match_rate) AS avg_match_rate
|
||||
FROM blocks
|
||||
JOIN blocks_audits ON blocks.height = blocks_audits.height
|
||||
WHERE blocks.pool_id = ?
|
||||
WHERE blocks.pool_id = ? AND stale = 0
|
||||
`;
|
||||
params.push(poolId);
|
||||
|
||||
|
|
@ -446,7 +456,7 @@ class BlocksRepository {
|
|||
const query = `
|
||||
SELECT sum(reward) as total_reward
|
||||
FROM blocks
|
||||
WHERE blocks.pool_id = ?
|
||||
WHERE blocks.pool_id = ? AND stale = 0
|
||||
`;
|
||||
params.push(poolId);
|
||||
|
||||
|
|
@ -468,6 +478,7 @@ class BlocksRepository {
|
|||
public async $oldestBlockTimestamp(): Promise<number> {
|
||||
const query = `SELECT UNIX_TIMESTAMP(blockTimestamp) as blockTimestamp
|
||||
FROM blocks
|
||||
WHERE stale = 0
|
||||
ORDER BY height
|
||||
LIMIT 1;`;
|
||||
|
||||
|
|
@ -499,7 +510,7 @@ class BlocksRepository {
|
|||
SELECT ${BLOCK_DB_FIELDS}
|
||||
FROM blocks
|
||||
JOIN pools ON blocks.pool_id = pools.id
|
||||
WHERE pool_id = ?`;
|
||||
WHERE pool_id = ? AND stale = 0`;
|
||||
params.push(pool.id);
|
||||
|
||||
if (startHeight !== undefined) {
|
||||
|
|
@ -534,7 +545,7 @@ class BlocksRepository {
|
|||
SELECT ${BLOCK_DB_FIELDS}
|
||||
FROM blocks
|
||||
JOIN pools ON blocks.pool_id = pools.id
|
||||
WHERE blocks.height = ?`,
|
||||
WHERE blocks.height = ? AND stale = 0`,
|
||||
[height]
|
||||
);
|
||||
|
||||
|
|
@ -549,12 +560,36 @@ class BlocksRepository {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one block by hash
|
||||
*/
|
||||
public async $getBlockByHash(hash: string): Promise<BlockExtended | null> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`
|
||||
SELECT ${BLOCK_DB_FIELDS}
|
||||
FROM blocks
|
||||
JOIN pools ON blocks.pool_id = pools.id
|
||||
WHERE blocks.hash = ?`,
|
||||
[hash]
|
||||
);
|
||||
|
||||
if (rows.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.formatDbBlockIntoExtendedBlock(rows[0] as DatabaseBlock);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get indexed block ${hash}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return blocks difficulty
|
||||
*/
|
||||
public async $getBlocksDifficulty(): Promise<object[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(blockTimestamp) as time, height, difficulty, bits FROM blocks ORDER BY height ASC`);
|
||||
const [rows]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(blockTimestamp) as time, height, difficulty, bits FROM blocks WHERE stale = 0 ORDER BY height ASC`);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot get blocks difficulty list from the db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
|
|
@ -573,7 +608,7 @@ class BlocksRepository {
|
|||
try {
|
||||
// Get first block at or after the given timestamp
|
||||
const query = `SELECT height, hash, blockTimestamp as timestamp FROM blocks
|
||||
WHERE blockTimestamp <= FROM_UNIXTIME(?)
|
||||
WHERE blockTimestamp <= FROM_UNIXTIME(?) AND stale = 0
|
||||
ORDER BY blockTimestamp DESC
|
||||
LIMIT 1`;
|
||||
const params = [timestamp];
|
||||
|
|
@ -602,6 +637,7 @@ class BlocksRepository {
|
|||
SELECT MIN(height) as startBlock, MAX(height) as endBlock, SUM(reward) as totalReward, SUM(fees) as totalFee, SUM(tx_count) as totalTx
|
||||
FROM
|
||||
(SELECT height, reward, fees, tx_count FROM blocks
|
||||
WHERE stale = 0
|
||||
ORDER by height DESC
|
||||
LIMIT ?) as sub`;
|
||||
|
||||
|
|
@ -615,44 +651,83 @@ class BlocksRepository {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if the chain of block hash is valid and delete data from the stale branch if needed
|
||||
* Check if the canonical chain of blocks is valid and fix it if needed
|
||||
*/
|
||||
public async $validateChain(): Promise<boolean> {
|
||||
try {
|
||||
const start = new Date().getTime();
|
||||
const tip = await bitcoinApi.$getBlockHashTip();
|
||||
let firstBadBlockHeight: number | null = null;
|
||||
const [blocks]: any[] = await DB.query(`
|
||||
SELECT
|
||||
height,
|
||||
hash,
|
||||
previous_block_hash,
|
||||
UNIX_TIMESTAMP(blockTimestamp) AS timestamp
|
||||
UNIX_TIMESTAMP(blockTimestamp) AS timestamp,
|
||||
stale
|
||||
FROM blocks
|
||||
ORDER BY height
|
||||
ORDER BY height DESC
|
||||
`);
|
||||
|
||||
let partialMsg = false;
|
||||
let idx = 1;
|
||||
while (idx < blocks.length) {
|
||||
if (blocks[idx].height - 1 !== blocks[idx - 1].height) {
|
||||
if (partialMsg === false) {
|
||||
logger.info('Some blocks are not indexed, skipping missing blocks during chain validation');
|
||||
partialMsg = true;
|
||||
}
|
||||
++idx;
|
||||
continue;
|
||||
const blocksByHash = {};
|
||||
const blocksByHeight = {};
|
||||
let minHeight = Infinity;
|
||||
for (const block of blocks) {
|
||||
blocksByHash[block.hash] = block;
|
||||
if (!blocksByHeight[block.height]) {
|
||||
blocksByHeight[block.height] = [block];
|
||||
} else {
|
||||
blocksByHeight[block.height].push(block);
|
||||
}
|
||||
|
||||
if (blocks[idx].previous_block_hash !== blocks[idx - 1].hash) {
|
||||
logger.warn(`Chain divergence detected at block ${blocks[idx - 1].height}`);
|
||||
await this.$deleteBlocksFrom(blocks[idx - 1].height);
|
||||
await HashratesRepository.$deleteHashratesFromTimestamp(blocks[idx - 1].timestamp - 604800);
|
||||
await DifficultyAdjustmentsRepository.$deleteAdjustementsFromHeight(blocks[idx - 1].height);
|
||||
return false;
|
||||
}
|
||||
++idx;
|
||||
minHeight = block.height;
|
||||
}
|
||||
|
||||
logger.debug(`${idx} blocks hash validated in ${new Date().getTime() - start} ms`);
|
||||
// ensure that indexed blocks are correctly classified as stale or canonical
|
||||
// iterate back to genesis, resetting canonical status where necessary
|
||||
let hash = tip;
|
||||
const tipHeight = blocksByHash[hash].height || (await bitcoinApi.$getBlock(hash))?.height;
|
||||
|
||||
// stop at the last canonical block we're supposed to have indexed already
|
||||
let lastIndexedBlockHeight = minHeight;
|
||||
const indexedBlockAmount = Math.min(config.MEMPOOL.INDEXING_BLOCKS_AMOUNT, tipHeight);
|
||||
if (indexedBlockAmount > 0) {
|
||||
lastIndexedBlockHeight = Math.max(0, tipHeight - indexedBlockAmount + 1);
|
||||
}
|
||||
|
||||
|
||||
for (let height = tipHeight; height > lastIndexedBlockHeight; height--) {
|
||||
const block = blocksByHash[hash];
|
||||
if (!block) {
|
||||
// block hasn't been indexed
|
||||
// mark any other blocks at this height as stale
|
||||
if (blocksByHeight[height]?.length > 1) {
|
||||
await this.$setCanonicalBlockAtHeight(null, height);
|
||||
}
|
||||
} else if (block.stale) {
|
||||
// block is marked stale, but shouldn't be
|
||||
await this.$setCanonicalBlockAtHeight(block.hash, height);
|
||||
firstBadBlockHeight = height;
|
||||
}
|
||||
hash = block?.previous_block_hash;
|
||||
if (!hash) {
|
||||
if (height < minHeight) {
|
||||
// we haven't indexed anything below this height anyway
|
||||
height = -1;
|
||||
break;
|
||||
} else {
|
||||
logger.info('Some blocks are not indexed, looking up prevhashes directly for chain validation');
|
||||
hash = await bitcoinApi.$getBlockHash(height - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstBadBlockHeight != null) {
|
||||
logger.warn(`Chain divergence detected at block ${firstBadBlockHeight}`);
|
||||
await HashratesRepository.$deleteHashratesFromTimestamp(blocksByHash[firstBadBlockHeight].timestamp - 604800);
|
||||
await DifficultyAdjustmentsRepository.$deleteAdjustementsFromHeight(firstBadBlockHeight);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.debug(`validated best chain of ${tipHeight} blocks in ${new Date().getTime() - start} ms`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.err('Cannot validate chain of block hash. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
|
|
@ -660,19 +735,6 @@ class BlocksRepository {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete blocks from the database from blockHeight
|
||||
*/
|
||||
public async $deleteBlocksFrom(blockHeight: number) {
|
||||
logger.info(`Delete newer blocks from height ${blockHeight} from the database`, logger.tags.mining);
|
||||
|
||||
try {
|
||||
await DB.query(`DELETE FROM blocks where height >= ${blockHeight}`);
|
||||
} catch (e) {
|
||||
logger.err('Cannot delete indexed blocks. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the historical averaged block fees
|
||||
*/
|
||||
|
|
@ -686,12 +748,13 @@ class BlocksRepository {
|
|||
FROM blocks
|
||||
JOIN blocks_prices on blocks_prices.height = blocks.height
|
||||
JOIN prices on prices.id = blocks_prices.price_id
|
||||
WHERE stale = 0
|
||||
`;
|
||||
|
||||
if (interval !== null) {
|
||||
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
} else if (timespan) {
|
||||
query += ` WHERE blockTimestamp BETWEEN FROM_UNIXTIME(${timespan.from}) AND FROM_UNIXTIME(${timespan.to})`;
|
||||
query += ` AND blockTimestamp BETWEEN FROM_UNIXTIME(${timespan.from}) AND FROM_UNIXTIME(${timespan.to})`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`;
|
||||
|
|
@ -717,10 +780,11 @@ class BlocksRepository {
|
|||
FROM blocks
|
||||
JOIN blocks_prices on blocks_prices.height = blocks.height
|
||||
JOIN prices on prices.id = blocks_prices.price_id
|
||||
WHERE stale = 0
|
||||
`;
|
||||
|
||||
if (interval !== null) {
|
||||
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`;
|
||||
|
|
@ -748,10 +812,11 @@ class BlocksRepository {
|
|||
CAST(AVG(JSON_EXTRACT(fee_span, '$[4]')) as INT) as avgFee_75,
|
||||
CAST(AVG(JSON_EXTRACT(fee_span, '$[5]')) as INT) as avgFee_90,
|
||||
CAST(AVG(JSON_EXTRACT(fee_span, '$[6]')) as INT) as avgFee_100
|
||||
FROM blocks`;
|
||||
FROM blocks
|
||||
WHERE stale = 0`;
|
||||
|
||||
if (interval !== null) {
|
||||
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`;
|
||||
|
|
@ -773,10 +838,11 @@ class BlocksRepository {
|
|||
CAST(AVG(height) as INT) as avgHeight,
|
||||
CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp,
|
||||
CAST(AVG(size) as INT) as avgSize
|
||||
FROM blocks`;
|
||||
FROM blocks
|
||||
WHERE stale = 0`;
|
||||
|
||||
if (interval !== null) {
|
||||
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`;
|
||||
|
|
@ -798,10 +864,11 @@ class BlocksRepository {
|
|||
CAST(AVG(height) as INT) as avgHeight,
|
||||
CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp,
|
||||
CAST(AVG(weight) as INT) as avgWeight
|
||||
FROM blocks`;
|
||||
FROM blocks
|
||||
WHERE stale = 0`;
|
||||
|
||||
if (interval !== null) {
|
||||
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`;
|
||||
|
|
@ -816,11 +883,12 @@ class BlocksRepository {
|
|||
|
||||
/**
|
||||
* Get a list of blocks that have been indexed
|
||||
* (includes stale blocks)
|
||||
*/
|
||||
public async $getIndexedBlocks(): Promise<{ height: number, hash: string }[]> {
|
||||
public async $getIndexedBlocks(): Promise<{ height: number, hash: string, stale: boolean }[]> {
|
||||
try {
|
||||
const [rows] = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`) as RowDataPacket[][];
|
||||
return rows as { height: number, hash: string }[];
|
||||
const [rows] = await DB.query(`SELECT height, hash, stale FROM blocks ORDER BY height DESC`) as RowDataPacket[][];
|
||||
return rows as { height: number, hash: string, stale: boolean }[];
|
||||
} catch (e) {
|
||||
logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
|
|
@ -865,7 +933,7 @@ class BlocksRepository {
|
|||
*/
|
||||
public async $getOldestConsecutiveBlock(): Promise<any> {
|
||||
try {
|
||||
const [rows]: any = await DB.query(`SELECT height, UNIX_TIMESTAMP(blockTimestamp) as timestamp, difficulty, bits FROM blocks ORDER BY height DESC`);
|
||||
const [rows]: any = await DB.query(`SELECT height, UNIX_TIMESTAMP(blockTimestamp) as timestamp, difficulty, bits FROM blocks WHERE stale = 0 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];
|
||||
|
|
@ -929,7 +997,7 @@ class BlocksRepository {
|
|||
SELECT height, hash
|
||||
FROM blocks
|
||||
WHERE height >= ${minHeight} AND height <= ${maxHeight} AND
|
||||
(utxoset_size IS NULL OR total_input_amt IS NULL)
|
||||
(utxoset_size IS NULL OR total_input_amt IS NULL) AND stale = 0
|
||||
`);
|
||||
return blocks;
|
||||
} catch (e) {
|
||||
|
|
@ -940,6 +1008,7 @@ class BlocksRepository {
|
|||
|
||||
/**
|
||||
* Get all indexed blocks with missing coinbase addresses
|
||||
* (includes stale blocks)
|
||||
*/
|
||||
public async $getBlocksWithoutCoinbaseAddresses(): Promise<any> {
|
||||
try {
|
||||
|
|
@ -1051,6 +1120,34 @@ class BlocksRepository {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change which block at a height belongs to the canonical chain
|
||||
*
|
||||
* @param hash
|
||||
* @param height
|
||||
*/
|
||||
public async $setCanonicalBlockAtHeight(hash: string | null, height: number): Promise<void> {
|
||||
try {
|
||||
// do this first, so that we fail if the block hasn't actually been indexed yet
|
||||
if (hash) {
|
||||
await DB.query(`
|
||||
UPDATE blocks SET stale = 0
|
||||
WHERE hash = ?`,
|
||||
[hash]
|
||||
);
|
||||
}
|
||||
// all other blocks at this height must be stale
|
||||
await DB.query(`
|
||||
UPDATE blocks SET stale = 1
|
||||
WHERE height = ? AND hash != ?`,
|
||||
[height, hash ?? '']
|
||||
);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot set canonical block at height. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a mysql row block into a BlockExtended. Note that you
|
||||
* must provide the correct field into dbBlk object param
|
||||
|
|
@ -1134,11 +1231,10 @@ class BlocksRepository {
|
|||
{
|
||||
extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id);
|
||||
if (extras.feePercentiles === null) {
|
||||
|
||||
let summary;
|
||||
let summaryVersion = 0;
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
const txs = (await bitcoinApi.$getTxsForBlock(dbBlk.id)).map(tx => transactionUtils.extendTransaction(tx));
|
||||
const txs = (await bitcoinApi.$getTxsForBlock(dbBlk.id, dbBlk.stale)).map(tx => transactionUtils.extendTransaction(tx));
|
||||
summary = blocks.summarizeBlockTransactions(dbBlk.id, dbBlk.height, txs);
|
||||
summaryVersion = 1;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { RowDataPacket } from 'mysql2';
|
||||
import { Common } from '../api/common';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { BlockSummary, TransactionClassified } from '../mempool.interfaces';
|
||||
|
|
@ -192,6 +193,19 @@ class BlocksSummariesRepository {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async $isSummaryIndexed(id: string): Promise<boolean> {
|
||||
if (!Common.blocksSummariesIndexingEnabled()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries WHERE id = ?`, [id]);
|
||||
return rows.length > 0;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot check if block summary is indexed. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default new BlocksSummariesRepository();
|
||||
|
|
|
|||
|
|
@ -45,10 +45,11 @@ class PoolsRepository {
|
|||
FROM blocks
|
||||
JOIN pools on pools.id = pool_id
|
||||
LEFT JOIN blocks_audits ON blocks_audits.height = blocks.height
|
||||
WHERE blocks.stale = 0
|
||||
`;
|
||||
|
||||
if (interval) {
|
||||
query += ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
query += ` AND blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY pool_id
|
||||
|
|
@ -70,6 +71,7 @@ class PoolsRepository {
|
|||
const query = `SELECT COUNT(height) as blockCount, pools.id as poolId, pools.name as poolName
|
||||
FROM pools
|
||||
LEFT JOIN blocks on pools.id = blocks.pool_id AND blocks.blockTimestamp BETWEEN FROM_UNIXTIME(?) AND FROM_UNIXTIME(?)
|
||||
WHERE blocks.stale = 0
|
||||
GROUP BY pools.id`;
|
||||
|
||||
try {
|
||||
|
|
@ -100,7 +102,7 @@ class PoolsRepository {
|
|||
if (parse) {
|
||||
rows[0].regexes = JSON.parse(rows[0].regexes);
|
||||
}
|
||||
if (['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (['testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
rows[0].addresses = []; // pools-v2.json only contains mainnet addresses
|
||||
} else if (parse) {
|
||||
rows[0].addresses = JSON.parse(rows[0].addresses);
|
||||
|
|
@ -132,7 +134,7 @@ class PoolsRepository {
|
|||
if (parse) {
|
||||
rows[0].regexes = JSON.parse(rows[0].regexes);
|
||||
}
|
||||
if (['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (['testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
rows[0].addresses = []; // pools.json only contains mainnet addresses
|
||||
} else if (parse) {
|
||||
rows[0].addresses = JSON.parse(rows[0].addresses);
|
||||
|
|
|
|||
|
|
@ -269,17 +269,16 @@ class NetworkSyncService {
|
|||
|
||||
private async $scanForClosedChannels(): Promise<void> {
|
||||
let currentBlockHeight = blocks.getCurrentBlockHeight();
|
||||
if (config.MEMPOOL.ENABLED === false) { // https://github.com/mempool/mempool/issues/3582
|
||||
currentBlockHeight = await bitcoinApi.$getBlockHeightTip();
|
||||
}
|
||||
if (this.closedChannelsScanBlock === currentBlockHeight) {
|
||||
logger.debug(`We've already scan closed channels for this block, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let progress = 0;
|
||||
|
||||
try {
|
||||
if (config.MEMPOOL.ENABLED === false) { // https://github.com/mempool/mempool/issues/3582
|
||||
currentBlockHeight = await bitcoinApi.$getBlockHeightTip();
|
||||
}
|
||||
if (this.closedChannelsScanBlock === currentBlockHeight) {
|
||||
logger.debug(`We've already scan closed channels for this block, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let progress = 0;
|
||||
let log = `Starting closed channels scan`;
|
||||
if (this.closedChannelsScanBlock > 0) {
|
||||
log += `. Last scan was at block ${this.closedChannelsScanBlock}`;
|
||||
|
|
|
|||
|
|
@ -74,11 +74,15 @@ class FundingTxFetcher {
|
|||
public async $fetchChannelOpenTx(channelId: string): Promise<{timestamp: number, txid: string, value: number} | null> {
|
||||
channelId = Common.channelIntegerIdToShortId(channelId);
|
||||
|
||||
if (!channelId?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.fundingTxCache[channelId]) {
|
||||
return this.fundingTxCache[channelId];
|
||||
}
|
||||
|
||||
const parts = channelId.split('x');
|
||||
const parts = channelId?.split('x') ?? [];
|
||||
if (parts.length < 3) {
|
||||
logger.debug(`Channel ID ${channelId} does not seem valid, should contains at least 3 parts separated by 'x'`, logger.tags.ln);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export async function $lookupNodeLocation(): Promise<void> {
|
|||
} catch (e) { }
|
||||
|
||||
for (const node of nodes) {
|
||||
const sockets: string[] = node.sockets.split(',');
|
||||
const sockets: string[] = node.sockets?.split(',') ?? [];
|
||||
for (const socket of sockets) {
|
||||
const ip = socket.substring(0, socket.lastIndexOf(':')).replace('[', '').replace(']', '');
|
||||
const hasClearnet = [4, 6].includes(net.isIP(ip));
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ class LightningStatsImporter {
|
|||
|
||||
for (const channel of networkGraph.edges) {
|
||||
const short_id = Common.channelIntegerIdToShortId(channel.channel_id);
|
||||
if (!short_id?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tx = await fundingTxFetcher.$fetchChannelOpenTx(short_id);
|
||||
if (!tx) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class PoolsUpdater {
|
|||
}
|
||||
|
||||
public async updatePoolsJson(): Promise<void> {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false ||
|
||||
if (['mainnet', 'testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK) === false ||
|
||||
config.MEMPOOL.ENABLED === false
|
||||
) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ class PriceUpdater {
|
|||
private feeds: PriceFeed[] = [];
|
||||
private currencies: string[] = ['USD', 'EUR', 'GBP', 'CAD', 'CHF', 'AUD', 'JPY'];
|
||||
private latestPrices: ApiPrice;
|
||||
private latestGoodPrices: ApiPrice;
|
||||
private currencyConversionFeed: ConversionFeed | undefined;
|
||||
private newCurrencies: string[] = ['BGN', 'BRL', 'CNY', 'CZK', 'DKK', 'HKD', 'HRK', 'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR'];
|
||||
private lastTimeConversionsRatesFetched: number = 0;
|
||||
|
|
@ -64,6 +65,7 @@ class PriceUpdater {
|
|||
|
||||
constructor() {
|
||||
this.latestPrices = this.getEmptyPricesObj();
|
||||
this.latestGoodPrices = this.getEmptyPricesObj();
|
||||
|
||||
this.feeds.push(new BitflyerApi()); // Does not have historical endpoint
|
||||
this.feeds.push(new KrakenApi());
|
||||
|
|
@ -76,7 +78,7 @@ class PriceUpdater {
|
|||
}
|
||||
|
||||
public getLatestPrices(): ApiPrice {
|
||||
return this.latestPrices;
|
||||
return this.latestGoodPrices;
|
||||
}
|
||||
|
||||
public getEmptyPricesObj(): ApiPrice {
|
||||
|
|
@ -128,10 +130,11 @@ class PriceUpdater {
|
|||
*/
|
||||
public async $initializeLatestPriceWithDb(): Promise<void> {
|
||||
this.latestPrices = await PricesRepository.$getLatestConversionRates();
|
||||
this.latestGoodPrices = JSON.parse(JSON.stringify(this.latestPrices));
|
||||
}
|
||||
|
||||
public async $run(): Promise<void> {
|
||||
if (config.MEMPOOL.NETWORK === 'signet' || config.MEMPOOL.NETWORK === 'testnet') {
|
||||
if (['testnet', 'signet', 'testnet4'].includes(config.MEMPOOL.NETWORK)) {
|
||||
// Coins have no value on testnet/signet, so we want to always show 0
|
||||
return;
|
||||
}
|
||||
|
|
@ -179,6 +182,14 @@ class PriceUpdater {
|
|||
this.running = false;
|
||||
}
|
||||
|
||||
private setLatestPrice(currency, price): void {
|
||||
this.latestPrices[currency] = price;
|
||||
if (price > 0) {
|
||||
this.latestGoodPrices[currency] = price;
|
||||
this.latestGoodPrices.time = Math.round(new Date().getTime() / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private getMillisecondsSinceBeginningOfHour(): number {
|
||||
const now = new Date();
|
||||
const beginningOfHour = new Date(now);
|
||||
|
|
@ -245,16 +256,16 @@ class PriceUpdater {
|
|||
// Compute average price, non weighted
|
||||
prices = prices.filter(price => price > 0);
|
||||
if (prices.length === 0) {
|
||||
this.latestPrices[currency] = -1;
|
||||
this.setLatestPrice(currency, -1);
|
||||
} else {
|
||||
this.latestPrices[currency] = Math.round(getMedian(prices));
|
||||
this.setLatestPrice(currency, Math.round(getMedian(prices)));
|
||||
}
|
||||
}
|
||||
|
||||
if (config.FIAT_PRICE.API_KEY && this.latestPrices.USD > 0 && Object.keys(this.latestConversionsRatesFromFeed).length > 0) {
|
||||
for (const conversionCurrency of this.newCurrencies) {
|
||||
if (this.latestConversionsRatesFromFeed[conversionCurrency] > 0 && this.latestPrices.USD * this.latestConversionsRatesFromFeed[conversionCurrency] < MAX_PRICES[conversionCurrency]) {
|
||||
this.latestPrices[conversionCurrency] = Math.round(this.latestPrices.USD * this.latestConversionsRatesFromFeed[conversionCurrency]);
|
||||
this.setLatestPrice(conversionCurrency, Math.round(this.latestPrices.USD * this.latestConversionsRatesFromFeed[conversionCurrency]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -277,14 +288,13 @@ class PriceUpdater {
|
|||
}
|
||||
|
||||
if (this.latestPrices.USD === -1) {
|
||||
this.latestPrices = await PricesRepository.$getLatestConversionRates();
|
||||
logger.warn(`No BTC price available, falling back to latest known price: ${JSON.stringify(this.latestPrices)}`);
|
||||
logger.warn(`No BTC price available, falling back to latest known price: ${JSON.stringify(this.latestGoodPrices)}`);
|
||||
} else {
|
||||
logger.info(`Latest BTC fiat averaged price: ${JSON.stringify(this.latestPrices)}`);
|
||||
logger.info(`Latest BTC fiat averaged price: ${JSON.stringify(this.latestGoodPrices)}`);
|
||||
}
|
||||
|
||||
if (this.ratesChangedCallback && this.latestPrices.USD > 0) {
|
||||
this.ratesChangedCallback(this.latestPrices);
|
||||
if (this.ratesChangedCallback && this.latestGoodPrices.USD > 0) {
|
||||
this.ratesChangedCallback(this.latestGoodPrices);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export async function query(path, throwOnFail: boolean = false): Promise<object
|
|||
headers: {
|
||||
'User-Agent': (config.MEMPOOL.USER_AGENT === 'mempool') ? `mempool/v${backendInfo.getBackendInfo().version}` : `${config.MEMPOOL.USER_AGENT}`
|
||||
},
|
||||
timeout: config.SOCKS5PROXY.ENABLED ? 30000 : 10000
|
||||
timeout: config.SOCKS5PROXY.ENABLED ? 30000 : 20000
|
||||
};
|
||||
let retry = 0;
|
||||
let lastError: any = null;
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export { opcodes };
|
|||
|
||||
/** extracts m and n from a multisig script (asm), returns nothing if it is not a multisig script */
|
||||
export function parseMultisigScript(script: string): void | { m: number, n: number } {
|
||||
if (!script) {
|
||||
if (!script?.length) {
|
||||
return;
|
||||
}
|
||||
const ops = script.split(' ');
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ function extractDateFromLogLine(line: string): number | undefined {
|
|||
|
||||
const dateStr = dateMatch[0];
|
||||
const date = new Date(dateStr);
|
||||
let timestamp = Math.floor(date.getTime() / 1000); // Remove decimal (microseconds are added later)
|
||||
const timestamp = Math.floor(date.getTime() / 1000); // Remove decimal (microseconds are added later)
|
||||
|
||||
const timePart = dateStr.split('T')[1];
|
||||
const microseconds = timePart.split('.')[1] || '';
|
||||
|
|
@ -41,14 +41,18 @@ export function getRecentFirstSeen(hash: string): number | undefined {
|
|||
if (debugLogPath) {
|
||||
try {
|
||||
// Read the last few lines of debug.log
|
||||
const lines = readFile(debugLogPath, 2048);
|
||||
const lines = readFile(debugLogPath, 4096).reverse();
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (line && line.includes(`Saw new header hash=${hash}`)) {
|
||||
let bestMatch: number | undefined;
|
||||
for (const line of lines) {
|
||||
if (line.includes(`Saw new header hash=${hash}`) || line.includes(`Saw new cmpctblock header hash=${hash}`)) {
|
||||
return extractDateFromLogLine(line);
|
||||
} else if (line.includes(`UpdateTip: new best=${hash}`)) {
|
||||
bestMatch = extractDateFromLogLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot parse block first seen time from Core logs. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
|
|
|||
14
backend/testSetup.integration.ts
Normal file
14
backend/testSetup.integration.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Integration test setup - uses real implementations, not mocks
|
||||
//
|
||||
// Note: We don't mock ./mempool-config.json here because:
|
||||
// 1. The MEMPOOL_CONFIG_FILE env var points to mempool-config.test.json
|
||||
// 2. config.ts will load that file via require() if env var is set
|
||||
// 3. If we mock it, we interfere with the actual config loading
|
||||
//
|
||||
// Don't mock these for integration tests - we want real implementations:
|
||||
// - logger.ts (need real logging)
|
||||
// - config.ts (need real config from mempool-config.test.json)
|
||||
// - rbf-cache.ts (might be used by repositories)
|
||||
// - mempool.ts (might be used by repositories)
|
||||
// - memory-cache.ts (might be used by repositories)
|
||||
|
||||
|
|
@ -1,5 +1,20 @@
|
|||
jest.mock('./mempool-config.json', () => ({}), { virtual: true });
|
||||
jest.mock('./src/logger.ts', () => ({}), { virtual: true });
|
||||
jest.mock('./src/logger.ts', () => ({
|
||||
emerg: jest.fn(),
|
||||
alert: jest.fn(),
|
||||
crit: jest.fn(),
|
||||
err: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
notice: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
updateNetwork: jest.fn(),
|
||||
tags: {
|
||||
mining: 'mining',
|
||||
ln: 'ln',
|
||||
goggles: 'goggles',
|
||||
},
|
||||
}), { virtual: true });
|
||||
jest.mock('./src/api/rbf-cache.ts', () => ({}), { virtual: true });
|
||||
jest.mock('./src/api/mempool.ts', () => ({}), { virtual: true });
|
||||
jest.mock('./src/api/memory-cache.ts', () => ({}), { virtual: true });
|
||||
|
|
|
|||
3
contributors/AaronDewes.txt
Normal file
3
contributors/AaronDewes.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of October 31, 2025.
|
||||
|
||||
Signed: AaronDewes
|
||||
3
contributors/achow101.txt
Normal file
3
contributors/achow101.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of October 12, 2025.
|
||||
|
||||
Signed: achow101
|
||||
|
|
@ -38,6 +38,7 @@ services:
|
|||
MYSQL_USER: "mempool"
|
||||
MYSQL_PASSWORD: "mempool"
|
||||
MYSQL_ROOT_PASSWORD: "admin"
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
image: mariadb:10.5.21
|
||||
user: "1000:1000"
|
||||
restart: on-failure
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@ __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
|
|||
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
|
||||
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
|
||||
|
||||
__PROXIED_SERVICES__=${PROXIED_SERVICES:=false}
|
||||
__PROXIED_SERVICES_HOST__=${PROXIED_SERVICES_HOST:=https://mempool.space}
|
||||
|
||||
if [ "${__PROXIED_SERVICES__}" = "true" ]; then
|
||||
sed -i "s|proxy_pass https://mempool.space;|proxy_pass ${__PROXIED_SERVICES_HOST__};|g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
fi
|
||||
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
|
|
|
|||
|
|
@ -181,8 +181,8 @@
|
|||
"inject": false
|
||||
},
|
||||
{
|
||||
"input": "src/theme-wiz.scss",
|
||||
"bundleName": "wiz",
|
||||
"input": "src/theme-softsimon.scss",
|
||||
"bundleName": "softsimon",
|
||||
"inject": false
|
||||
},
|
||||
{
|
||||
|
|
@ -197,7 +197,10 @@
|
|||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
"namedChunks": true,
|
||||
"allowedCommonJsDependencies": [
|
||||
"qrcode"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
"component": "twitter",
|
||||
"mobileOrder": 5,
|
||||
"props": {
|
||||
"handle": "Metaplanet_JP"
|
||||
"handle": "Metaplanet"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@
|
|||
"props": {
|
||||
"wallet": "ONBTC",
|
||||
"period": "1m",
|
||||
"label": "bitcoin.gob.sv"
|
||||
"label": "bitcoin.gob.sv",
|
||||
"image": "/resources/elsalvador-flag.svg"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// source: chrisp_68 @ https://stackoverflow.com/questions/50525143/how-do-you-reliably-wait-for-page-idle-in-cypress-io-test
|
||||
export class PageIdleDetector
|
||||
{
|
||||
defaultOptions: Object = { timeout: 60000 };
|
||||
defaultOptions: object = { timeout: 60000 };
|
||||
|
||||
public WaitForPageToBeIdle(): void
|
||||
{
|
||||
|
|
@ -11,7 +11,7 @@ export class PageIdleDetector
|
|||
this.WaitForAnimationsToStop();
|
||||
}
|
||||
|
||||
public WaitForPageToLoad(options: Object = this.defaultOptions): void
|
||||
public WaitForPageToLoad(options: object = this.defaultOptions): void
|
||||
{
|
||||
cy.document(options).should((myDocument: any) =>
|
||||
{
|
||||
|
|
@ -19,7 +19,7 @@ export class PageIdleDetector
|
|||
});
|
||||
}
|
||||
|
||||
public WaitForAngularRequestsToComplete(options: Object = this.defaultOptions): void
|
||||
public WaitForAngularRequestsToComplete(options: object = this.defaultOptions): void
|
||||
{
|
||||
cy.window(options).should((myWindow: any) =>
|
||||
{
|
||||
|
|
@ -30,7 +30,7 @@ export class PageIdleDetector
|
|||
});
|
||||
}
|
||||
|
||||
public WaitForAngularDigestCycleToComplete(options: Object = this.defaultOptions): void
|
||||
public WaitForAngularDigestCycleToComplete(options: object = this.defaultOptions): void
|
||||
{
|
||||
cy.window(options).should((myWindow: any) =>
|
||||
{
|
||||
|
|
@ -41,7 +41,7 @@ export class PageIdleDetector
|
|||
});
|
||||
}
|
||||
|
||||
public WaitForAnimationsToStop(options: Object = this.defaultOptions): void
|
||||
public WaitForAnimationsToStop(options: object = this.defaultOptions): void
|
||||
{
|
||||
cy.get(":animated", options).should("not.exist");
|
||||
}
|
||||
|
|
|
|||
92
frontend/eslint.config.js
Normal file
92
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import js from '@eslint/js';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
|
||||
// Flat config migrated from legacy .eslintrc
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'src/resources/**',
|
||||
// Keep parity with legacy .eslintignore
|
||||
'frontend/**',
|
||||
'server.run.js',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
// Node globals for local JS utility scripts in this package
|
||||
{
|
||||
// Apply to all JS files in this package (including nested ones)
|
||||
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
require: 'readonly',
|
||||
module: 'readonly',
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
console: 'readonly',
|
||||
InitWally: 'readonly',
|
||||
document: 'readonly',
|
||||
window: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
// Make common browser globals available in TS as well
|
||||
globals: {
|
||||
document: 'readonly',
|
||||
window: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
},
|
||||
rules: {
|
||||
// Adjust base recommended rules for TypeScript (matches legacy extends: plugin:@typescript-eslint/eslint-recommended)
|
||||
...tsPlugin.configs['eslint-recommended']?.overrides?.[0]?.rules,
|
||||
// Start from @typescript-eslint's recommended rules
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
|
||||
// Project-specific rules migrated from .eslintrc
|
||||
'@typescript-eslint/ban-ts-comment': 'warn',
|
||||
'@typescript-eslint/no-empty-function': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-inferrable-types': 'off',
|
||||
'@typescript-eslint/no-namespace': 'warn',
|
||||
'@typescript-eslint/no-this-alias': 'warn',
|
||||
'@typescript-eslint/no-var-requires': 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-unused-expressions': 'warn',
|
||||
'@typescript-eslint/no-require-imports': 'warn',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'warn',
|
||||
'no-case-declarations': 'warn',
|
||||
'no-console': 'warn',
|
||||
'no-constant-condition': 'warn',
|
||||
'no-dupe-else-if': 'warn',
|
||||
'no-empty': 'warn',
|
||||
'no-extra-boolean-cast': 'warn',
|
||||
'no-prototype-builtins': 'warn',
|
||||
'no-self-assign': 'warn',
|
||||
'no-useless-catch': 'warn',
|
||||
'no-var': 'warn',
|
||||
'prefer-const': 'warn',
|
||||
'prefer-rest-params': 'warn',
|
||||
'quotes': ['warn', 'single', { allowTemplateLiterals: true }],
|
||||
'semi': 'warn',
|
||||
'curly': ['warn', 'all'],
|
||||
'eqeqeq': 'warn',
|
||||
'no-trailing-spaces': 'warn',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -29,7 +29,7 @@ if (configContent && configContent.CUSTOMIZATION) {
|
|||
try {
|
||||
customConfig = readConfig(configContent.CUSTOMIZATION);
|
||||
customConfigContent = JSON.parse(customConfig);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.log(`failed to load customization config from ${configContent.CUSTOMIZATION}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ try {
|
|||
throw new Error(e);
|
||||
}
|
||||
|
||||
for (setting in configContent) {
|
||||
for (const setting in configContent) {
|
||||
settings.push({
|
||||
key: setting,
|
||||
value: configContent[setting]
|
||||
|
|
@ -98,7 +98,7 @@ function readConfig(path) {
|
|||
try {
|
||||
const currentConfig = fs.readFileSync(path).toString().trim();
|
||||
return currentConfig;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -136,13 +136,11 @@ writeConfig(GENERATED_CUSTOMIZATION_FILE_NAME, customConfigJs);
|
|||
|
||||
if (currentConfig && currentConfig === newConfig) {
|
||||
console.log(`No configuration updates, skipping ${GENERATED_CONFIG_FILE_NAME} file update`);
|
||||
return;
|
||||
} else if (!currentConfig) {
|
||||
console.log(`${GENERATED_CONFIG_FILE_NAME} file not found, creating new config file`);
|
||||
console.log('CONFIG: ', newConfig);
|
||||
writeConfig(GENERATED_CONFIG_FILE_NAME, newConfig);
|
||||
console.log(`${GENERATED_CONFIG_FILE_NAME} file saved`);
|
||||
return;
|
||||
} else {
|
||||
console.log(`Configuration changes detected, updating ${GENERATED_CONFIG_FILE_NAME} file`);
|
||||
console.log('OLD CONFIG: ', currentConfig);
|
||||
|
|
|
|||
27736
frontend/package-lock.json
generated
27736
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -32,13 +32,10 @@
|
|||
"start:local-esplora": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-esplora",
|
||||
"start:local-prod": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-prod",
|
||||
"start:mixed": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c mixed",
|
||||
"build": "npm run generate-config && npm run ng -- build --configuration production --localize && npm run sync-assets-dev && npm run sync-assets && npm run build-mempool.js",
|
||||
"build": "npm run generate-config && npm run ng -- build --configuration production --localize && npm run sync-assets-dev && npm run sync-assets",
|
||||
"sync-assets": "rsync -av ./src/resources ./dist/mempool/browser && node sync-assets.js 'dist/mempool/browser/resources/'",
|
||||
"sync-assets-dev": "node sync-assets.js 'src/resources/'",
|
||||
"generate-config": "node generate-config.js",
|
||||
"build-mempool.js": "npm run build-mempool-js && npm run build-mempool-liquid-js",
|
||||
"build-mempool-js": "browserify -p tinyify ./node_modules/@mempool/mempool.js/lib/index.js --standalone mempoolJS > ./dist/mempool/browser/en-US/mempool.js",
|
||||
"build-mempool-liquid-js": "browserify -p tinyify ./node_modules/@mempool/mempool.js/lib/index-liquid.js --standalone liquidJS > ./dist/mempool/browser/en-US/liquid.js",
|
||||
"test": "npm run ng -- test",
|
||||
"lint": "./node_modules/.bin/eslint . --ext .ts",
|
||||
"lint:fix": "./node_modules/.bin/eslint . --ext .ts --fix",
|
||||
|
|
@ -60,63 +57,58 @@
|
|||
"cypress:run:ci:parameterized": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:parameterized 4200 cypress:run:record"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular-devkit/build-angular": "^17.3.1",
|
||||
"@angular/animations": "^17.3.1",
|
||||
"@angular/cli": "^17.3.1",
|
||||
"@angular/common": "^17.3.1",
|
||||
"@angular/compiler": "^17.3.1",
|
||||
"@angular/core": "^17.3.1",
|
||||
"@angular/forms": "^17.3.1",
|
||||
"@angular/localize": "^17.3.1",
|
||||
"@angular/platform-browser": "^17.3.1",
|
||||
"@angular/platform-browser-dynamic": "^17.3.1",
|
||||
"@angular/platform-server": "^17.3.1",
|
||||
"@angular/router": "^17.3.1",
|
||||
"@angular/ssr": "^17.3.1",
|
||||
"@fortawesome/angular-fontawesome": "~0.14.1",
|
||||
"@angular-devkit/build-angular": "^20.3.13",
|
||||
"@angular/animations": "^20.3.15",
|
||||
"@angular/cli": "^20.3.13",
|
||||
"@angular/common": "^20.3.15",
|
||||
"@angular/compiler": "^20.3.15",
|
||||
"@angular/core": "^20.3.15",
|
||||
"@angular/forms": "^20.3.15",
|
||||
"@angular/localize": "^20.3.15",
|
||||
"@angular/platform-browser": "^20.3.15",
|
||||
"@angular/platform-browser-dynamic": "^20.3.15",
|
||||
"@angular/platform-server": "^20.3.15",
|
||||
"@angular/router": "^20.3.15",
|
||||
"@angular/ssr": "^20.3.13",
|
||||
"@fortawesome/angular-fontawesome": "^3.0.0",
|
||||
"@fortawesome/fontawesome-common-types": "~6.7.2",
|
||||
"@fortawesome/fontawesome-svg-core": "~6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "~6.7.2",
|
||||
"@mempool/mempool.js": "2.3.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^19.0.0",
|
||||
"@types/qrcode": "~1.5.0",
|
||||
"bootstrap": "~5.3.6",
|
||||
"browserify": "^17.0.0",
|
||||
"@noble/secp256k1": "^3.0.0",
|
||||
"clipboard": "^2.0.11",
|
||||
"domino": "^2.1.6",
|
||||
"echarts": "~5.6.0",
|
||||
"ngx-echarts": "~17.2.0",
|
||||
"ngx-infinite-scroll": "^17.0.0",
|
||||
"echarts": "~5.4.0",
|
||||
"ngx-echarts": "~20.0.2",
|
||||
"ngx-infinite-scroll": "^20.0.0",
|
||||
"qrcode": "1.5.1",
|
||||
"rxjs": "~7.8.1",
|
||||
"esbuild": "^0.25.8",
|
||||
"tinyify": "^4.0.0",
|
||||
"tlite": "^0.1.9",
|
||||
"tslib": "~2.8.0",
|
||||
"zone.js": "~0.14.4"
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/compiler-cli": "^17.3.1",
|
||||
"@angular/language-service": "^17.3.1",
|
||||
"@types/node": "^18.11.9",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"@angular/compiler-cli": "^20.3.15",
|
||||
"@angular/language-service": "^20.3.15",
|
||||
"@types/node": "^24.9.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
||||
"@typescript-eslint/parser": "^8.46.2",
|
||||
"eslint": "^9.39.0",
|
||||
"browser-sync": "^3.0.3",
|
||||
"http-proxy-middleware": "~2.0.6",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "~5.4.3"
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@cypress/schematic": "^2.5.0",
|
||||
"@types/cypress": "^1.1.3",
|
||||
"cypress": "^13.17.0",
|
||||
"cypress-fail-on-console-error": "~5.1.0",
|
||||
"cypress-wait-until": "^2.0.1",
|
||||
"cypress": "^15.8.1",
|
||||
"cypress-fail-on-console-error": "~5.1.1",
|
||||
"cypress-wait-until": "^3.0.1",
|
||||
"mock-socket": "~9.3.1",
|
||||
"start-server-and-test": "~2.0.0"
|
||||
"start-server-and-test": "~2.1.2"
|
||||
},
|
||||
"scarfSettings": {
|
||||
"enabled": false
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
const fs = require('fs');
|
||||
|
||||
const PROXY_CONFIG = require('./proxy.conf');
|
||||
|
||||
const addApiKeyHeader = (proxyReq, req, res) => {
|
||||
const addApiKeyHeader = (proxyReq) => {
|
||||
if (process.env.MEMPOOL_CI_API_KEY) {
|
||||
proxyReq.setHeader('X-Mempool-Auth', process.env.MEMPOOL_CI_API_KEY);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { ModuleWithProviders, NgModule } from '@angular/core';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ZONE_SERVICE } from '@app/injection-tokens';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
|
|
@ -50,7 +50,6 @@ const providers = [
|
|||
FiatShortenerPipe,
|
||||
FiatCurrencyPipe,
|
||||
CapAddressPipe,
|
||||
DatePipe,
|
||||
AppPreloadingStrategy,
|
||||
ServicesApiServices,
|
||||
PreloadService,
|
||||
|
|
@ -58,20 +57,19 @@ const providers = [
|
|||
{ provide: ZONE_SERVICE, useClass: ZoneService },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
HttpClientModule,
|
||||
BrowserAnimationsModule,
|
||||
SharedModule,
|
||||
],
|
||||
providers: providers,
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
@NgModule({ declarations: [
|
||||
AppComponent,
|
||||
],
|
||||
bootstrap: [AppComponent], imports: [BrowserModule,
|
||||
AppRoutingModule,
|
||||
BrowserAnimationsModule,
|
||||
SharedModule
|
||||
],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
DatePipe,
|
||||
...providers
|
||||
] })
|
||||
export class AppModule { }
|
||||
|
||||
@NgModule({})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { EnterpriseService } from '@app/services/enterprise.service';
|
|||
selector: 'app-about-sponsors',
|
||||
templateUrl: './about-sponsors.component.html',
|
||||
styleUrls: ['./about-sponsors.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AboutSponsorsComponent {
|
||||
@Input() host = 'https://mempool.space';
|
||||
|
|
|
|||
|
|
@ -277,9 +277,9 @@
|
|||
<img class="image" src="/resources/profile/ronindojo.png" />
|
||||
<span>RoninDojo</span>
|
||||
</a>
|
||||
<a href="https://github.com/runcitadel" target="_blank" title="Citadel">
|
||||
<img class="image" src="/resources/profile/runcitadel.svg" />
|
||||
<span>Citadel</span>
|
||||
<a href="https://gitlab.com/nirvati-ug/nirvati" target="_blank" title="Nirvati">
|
||||
<img class="image" src="/resources/profile/nirvati.svg" />
|
||||
<span>Nirvati</span>
|
||||
</a>
|
||||
<a href="https://github.com/fort-nix/nix-bitcoin" target="_blank" title="nix-bitcoin">
|
||||
<img class="image" src="/resources/profile/nix-bitcoin.png" />
|
||||
|
|
@ -427,16 +427,11 @@
|
|||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="maintainers" id="project-maintainers">
|
||||
<h3 i18n="about.maintainers">Project Maintainers</h3>
|
||||
<div class="managers" id="project-managers">
|
||||
<h3 i18n="about.project-manager">Powered By</h3>
|
||||
<div class="wrapper">
|
||||
<a href="https://x.com/softsimon_" target="_blank" title="softsimon">
|
||||
<img class="image" src="/resources/profile/softsimon.jpg" />
|
||||
<span>softsimon</span>
|
||||
</a>
|
||||
<a href="https://x.com/wiz" target="_blank" title="wiz">
|
||||
<img class="image" src="/resources/profile/wiz.png" />
|
||||
<span>wiz</span>
|
||||
<a href="https://wiz.biz" target="_blank" title="wiz & associates">
|
||||
<img class="wiz-logo" src="/resources/wiz.png" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -444,7 +439,7 @@
|
|||
<div class="copyright">
|
||||
<div class="title">
|
||||
Copyright © 2019-2025<br>
|
||||
Mempool Space K.K.<br>
|
||||
Mempool Holdings S.A. de C.V.<br>
|
||||
and other shadowy super-coders
|
||||
</div>
|
||||
<p>
|
||||
|
|
@ -460,7 +455,7 @@
|
|||
Trademark Notice<br>
|
||||
</div>
|
||||
<p>
|
||||
The Mempool Open Source Project®, Mempool Accelerator®, Mempool Enterprise®, Mempool Wallet™, mempool.space®, Be your own explorer™, Explore the full Bitcoin ecosystem®, Mempool Goggles™, the mempool Logo, the mempool Square Logo, the mempool block visualization Logo, the mempool Blocks Logo, the mempool transaction Logo, the mempool Blocks 3 | 2 Logo, the mempool research Logo, the mempool.space Vertical Logo, and the mempool.space Horizontal 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 Accelerator®, Mempool Enterprise®, Mempool Wallet™, mempool.space®, Be your own explorer™, Explore the full Bitcoin ecosystem®, Mempool Goggles™, the mempool Logo, the mempool Square Logo, the mempool block visualization Logo, the mempool Blocks Logo, the mempool transaction Logo, the mempool Blocks 3 | 2 Logo, the mempool research Logo, the mempool.space Vertical Logo, and the mempool.space Horizontal Logo are either registered trademarks or trademarks of Mempool Holdings S.A. de C.V. in Japan, the United States, and/or other countries.
|
||||
</p>
|
||||
<p>
|
||||
While our software is available under an open source software license, the copyright license does not include an implied right or license to use our trademarks. See our <a href="https://mempool.space/trademark-policy">Trademark Policy and Guidelines</a> for more details, published on <https://mempool.space/trademark-policy>.
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@
|
|||
.alliances,
|
||||
.enterprise-sponsor,
|
||||
.community-integrations-sponsor,
|
||||
.maintainers {
|
||||
.maintainers,
|
||||
.managers {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 68px;
|
||||
scroll-margin: 30px;
|
||||
|
|
@ -67,7 +68,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.maintainers {
|
||||
.maintainers,
|
||||
.managers {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +261,12 @@
|
|||
height: 64px;
|
||||
}
|
||||
|
||||
.wiz-logo {
|
||||
width: 260px;
|
||||
max-width: 90vw;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.enterprise-sponsor {
|
||||
.wrapper {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { EnterpriseService } from '@app/services/enterprise.service';
|
|||
selector: 'app-about',
|
||||
templateUrl: './about.component.html',
|
||||
styleUrls: ['./about.component.scss'],
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AboutComponent implements OnInit {
|
||||
|
|
|
|||
|
|
@ -572,22 +572,14 @@
|
|||
<div class="row mb-1 text-center">
|
||||
<div class="col-sm">
|
||||
<h1 style="font-size: larger;"><ng-content select="[slot='accelerated-title']"></ng-content>
|
||||
@if (accelerationResponse) {
|
||||
<span class="default-slot" i18n="accelerator.success-message">Your transaction is being accelerated!</span>
|
||||
} @else {
|
||||
<span class="default-slot" i18n="accelerator.success-message-third-party">Transaction is already being accelerated!</span>
|
||||
}
|
||||
<span class="default-slot" i18n="accelerator.success-message">Transaction is being accelerated!</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-center mt-1">
|
||||
<div class="col-sm">
|
||||
<div class="d-flex flex-row justify-content-center align-items-center">
|
||||
@if (accelerationResponse) {
|
||||
<span i18n="accelerator.confirmed-acceleration-with-miners">Your transaction has been accepted for acceleration by our mining pool partners.</span>
|
||||
} @else {
|
||||
<span i18n="accelerator.confirmed-acceleration-with-miners-third-party">Transaction has already been accepted for acceleration by our mining pool partners.</span>
|
||||
}
|
||||
<span i18n="accelerator.confirmed-acceleration-with-miners">Transaction has been accepted for acceleration by our mining pool partners.</span>
|
||||
</div>
|
||||
@if (accelerationResponse?.receiptUrl) {
|
||||
<div class="d-flex flex-row justify-content-center align-items-center">
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' |
|
|||
@Component({
|
||||
selector: 'app-accelerate-checkout',
|
||||
templateUrl: './accelerate-checkout.component.html',
|
||||
styleUrls: ['./accelerate-checkout.component.scss']
|
||||
styleUrls: ['./accelerate-checkout.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
@Input() tx: Transaction;
|
||||
|
|
@ -140,7 +141,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
private authService: AuthServiceMempool,
|
||||
private enterpriseService: EnterpriseService,
|
||||
) {
|
||||
this.isProdDomain = this.stateService.env.PROD_DOMAINS.indexOf(document.location.hostname) > -1;
|
||||
this.isProdDomain = this.stateService.isProdDomain;
|
||||
|
||||
// Check if Apple Pay available
|
||||
// https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview
|
||||
|
|
@ -215,8 +216,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
}
|
||||
if (this._step === 'checkout' && this.canPayWithBitcoin) {
|
||||
this.btcpayInvoiceFailed = false;
|
||||
this.invoice = null;
|
||||
this.invoice = undefined;
|
||||
this.requestBTCPayInvoice();
|
||||
this.scrollToElementWithTimeout('acceleratePreviewAnchor', 'start', 100);
|
||||
} else if (this._step === 'cashapp') {
|
||||
this.loadingCashapp = true;
|
||||
this.setupSquare();
|
||||
|
|
@ -474,19 +476,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
if (this.conversionsSubscription) {
|
||||
this.conversionsSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
this.conversionsSubscription = this.stateService.conversions$.subscribe(
|
||||
async (conversions) => {
|
||||
this.conversions = conversions;
|
||||
|
||||
if (this.applePay) {
|
||||
this.applePay.destroy();
|
||||
}
|
||||
|
||||
const costUSD = this.cost / 100_000_000 * conversions.USD;
|
||||
const costUSD = this.cost / 100_000_000 * this.conversions.USD;
|
||||
const paymentRequest = this.payments.paymentRequest({
|
||||
countryCode: 'US',
|
||||
currencyCode: 'USD',
|
||||
|
|
@ -585,8 +582,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
this.processing = false;
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -596,19 +591,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
if (this.conversionsSubscription) {
|
||||
this.conversionsSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
this.conversionsSubscription = this.stateService.conversions$.subscribe(
|
||||
async (conversions) => {
|
||||
this.conversions = conversions;
|
||||
|
||||
if (this.googlePay) {
|
||||
this.googlePay.destroy();
|
||||
}
|
||||
|
||||
const costUSD = this.cost / 100_000_000 * conversions.USD;
|
||||
const costUSD = this.cost / 100_000_000 * this.conversions.USD;
|
||||
const paymentRequest = this.payments.paymentRequest({
|
||||
countryCode: 'US',
|
||||
currencyCode: 'USD',
|
||||
|
|
@ -710,8 +700,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
this.isCheckoutLocked--;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -721,16 +709,10 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
if (this.conversionsSubscription) {
|
||||
this.conversionsSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
this.conversionsSubscription = this.stateService.conversions$.subscribe(
|
||||
async (conversions) => {
|
||||
this.conversions = conversions;
|
||||
|
||||
const costUSD = this.cost / 100_000_000 * conversions.USD;
|
||||
const costUSD = this.cost / 100_000_000 * this.conversions.USD;
|
||||
if (this.isCheckoutLocked > 0) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -815,8 +797,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
this.isCheckoutLocked--;
|
||||
this.isTokenizing--;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -826,20 +806,15 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
if (this.conversionsSubscription) {
|
||||
this.conversionsSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
this.conversionsSubscription = this.stateService.conversions$.subscribe(
|
||||
async (conversions) => {
|
||||
this.conversions = conversions;
|
||||
|
||||
if (this.cashAppPay) {
|
||||
this.cashAppPay.destroy();
|
||||
}
|
||||
|
||||
const redirectHostname = document.location.hostname === 'localhost' ? `http://localhost:4200`: `https://${document.location.hostname}`;
|
||||
const costUSD = this.cost / 100_000_000 * conversions.USD;
|
||||
const costUSD = this.cost / 100_000_000 * this.conversions.USD;
|
||||
const paymentRequest = this.payments.paymentRequest({
|
||||
countryCode: 'US',
|
||||
currencyCode: 'USD',
|
||||
|
|
@ -902,8 +877,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface GraphBar {
|
|||
selector: 'app-accelerate-fee-graph',
|
||||
templateUrl: './accelerate-fee-graph.component.html',
|
||||
styleUrls: ['./accelerate-fee-graph.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AccelerateFeeGraphComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy {
|
||||
@Input() tx: Transaction;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/cor
|
|||
selector: 'app-acceleration-timeline-tooltip',
|
||||
templateUrl: './acceleration-timeline-tooltip.component.html',
|
||||
styleUrls: ['./acceleration-timeline-tooltip.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AccelerationTimelineTooltipComponent implements OnChanges {
|
||||
@Input() accelerationInfo: any;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { MiningService } from '@app/services/mining.service';
|
|||
selector: 'app-acceleration-timeline',
|
||||
templateUrl: './acceleration-timeline.component.html',
|
||||
styleUrls: ['./acceleration-timeline.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AccelerationTimelineComponent implements OnInit, OnChanges {
|
||||
@Input() transactionTime: number;
|
||||
|
|
|
|||
|
|
@ -11,12 +11,6 @@
|
|||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="daysAvailable">
|
||||
<div class="btn-group" role="group" name="radioBasic">
|
||||
<input type="radio" class="btn-check" id="accelerationfees-24h" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" formControlName="dateSpan">
|
||||
<label class="btn btn-primary btn-sm" for="accelerationfees-24h">24h</label>
|
||||
<ng-container *ngIf="daysAvailable >= 1">
|
||||
<input type="radio" class="btn-check" id="accelerationfees-3d" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" formControlName="dateSpan">
|
||||
<label class="btn btn-primary btn-sm" for="accelerationfees-3d">3D</label>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="daysAvailable >= 3">
|
||||
<input type="radio" class="btn-check" id="accelerationfees-1w" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" formControlName="dateSpan">
|
||||
<label class="btn btn-primary btn-sm" for="accelerationfees-1w">1W</label>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
|
||||
import { EChartsOption } from '@app/graphs/echarts';
|
||||
import { echarts, EChartsOption } from '@app/graphs/echarts';
|
||||
import { Observable, Subject, Subscription, combineLatest, fromEvent, merge, share } from 'rxjs';
|
||||
import { startWith, switchMap, tap } from 'rxjs/operators';
|
||||
import { SeoService } from '@app/services/seo.service';
|
||||
|
|
@ -12,7 +12,6 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||
import { Acceleration } from '@interfaces/node-api.interface';
|
||||
import { ServicesApiServices } from '@app/services/services-api.service';
|
||||
import { StateService } from '@app/services/state.service';
|
||||
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-acceleration-fees-graph',
|
||||
|
|
@ -27,13 +26,14 @@ import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pip
|
|||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() widget: boolean = false;
|
||||
@Input() height: number = 300;
|
||||
@Input() right: number | string = 45;
|
||||
@Input() left: number | string = 75;
|
||||
@Input() period: '24h' | '3d' | '1w' | '1m' | 'all' = '1w';
|
||||
@Input() right: number | string = 70;
|
||||
@Input() left: number | string = 55;
|
||||
@Input() period: '24h' | '1w' | '1m' | '1y' | 'all' = '1y';
|
||||
@Input() accelerations$: Observable<Acceleration[]>;
|
||||
|
||||
miningWindowPreference: string;
|
||||
|
|
@ -51,7 +51,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
isLoading = true;
|
||||
formatNumber = formatNumber;
|
||||
timespan = '';
|
||||
periodSubject$: Subject<'24h' | '3d' | '1w' | '1m' | 'all'> = new Subject();
|
||||
periodSubject$: Subject<'24h' | '1w' | '1m' | '1y' | 'all'> = new Subject();
|
||||
chartInstance: any = undefined;
|
||||
daysAvailable: number = 0;
|
||||
|
||||
|
|
@ -64,9 +64,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
private miningService: MiningService,
|
||||
private route: ActivatedRoute,
|
||||
public stateService: StateService,
|
||||
private cd: ChangeDetectorRef,
|
||||
private router: Router,
|
||||
private zone: NgZone,
|
||||
private cd: ChangeDetectorRef
|
||||
) {
|
||||
this.radioGroupForm = this.formBuilder.group({ dateSpan: '1w' });
|
||||
this.radioGroupForm.controls.dateSpan.setValue('1w');
|
||||
|
|
@ -83,7 +81,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
|
||||
|
||||
this.fragmentSubscription = this.route.fragment.subscribe((fragment) => {
|
||||
if (['24h', '3d', '1w', '1m', '3m', 'all'].indexOf(fragment) > -1) {
|
||||
if (['1w', '1m', '1y', 'all'].indexOf(fragment) > -1) {
|
||||
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
|
|
@ -98,7 +96,9 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
if (!this.widget) {
|
||||
this.storageService.setValue('miningWindowPreference', timespan);
|
||||
}
|
||||
this.isLoading = true;
|
||||
if (timespan !== this.timespan) {
|
||||
this.isLoading = true;
|
||||
}
|
||||
this.timespan = timespan;
|
||||
return this.servicesApiService.getAggregatedAccelerationHistory$({timeframe: this.timespan});
|
||||
})
|
||||
|
|
@ -120,6 +120,9 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.period) {
|
||||
if (this.period === '24h') {
|
||||
this.period = '1m';
|
||||
}
|
||||
this.periodSubject$.next(this.period);
|
||||
}
|
||||
}
|
||||
|
|
@ -141,13 +144,19 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
this.chartOptions = {
|
||||
title: title,
|
||||
color: [
|
||||
'#8F5FF6',
|
||||
'#6b6b6b',
|
||||
new echarts.graphic.LinearGradient(0, 0, 0, 0.65, [
|
||||
{ offset: 0, color: '#F4511E' },
|
||||
{ offset: 0.25, color: '#FB8C00' },
|
||||
{ offset: 0.5, color: '#FFB300' },
|
||||
{ offset: 0.75, color: '#FDD835' },
|
||||
{ offset: 1, color: '#7CB342' }
|
||||
]),
|
||||
'#ab2dce',
|
||||
],
|
||||
animation: false,
|
||||
grid: {
|
||||
height: (this.widget && this.height) ? this.height - 30 : undefined,
|
||||
top: this.widget ? 20 : 40,
|
||||
height: (this.widget && this.height) ? this.height - 50 : undefined,
|
||||
top: this.widget ? 40 : 60,
|
||||
bottom: this.widget ? 30 : 80,
|
||||
right: this.right,
|
||||
left: this.left,
|
||||
|
|
@ -169,17 +178,18 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
formatter: (ticks) => {
|
||||
let tooltip = `<b style="color: white; margin-left: 2px">${formatterXAxis(this.locale, this.timespan, parseInt(ticks[0].axisValue, 10))}</b><br>`;
|
||||
|
||||
if (ticks[0].data[1] > 10_000_000) {
|
||||
tooltip += `${ticks[0].marker} ${ticks[0].seriesName}: ${formatNumber(ticks[0].data[1] / 100_000_000, this.locale, '1.0-8')} BTC<br>`;
|
||||
} else {
|
||||
tooltip += `${ticks[0].marker} ${ticks[0].seriesName}: ${formatNumber(ticks[0].data[1], this.locale, '1.0-0')} sats<br>`;
|
||||
}
|
||||
|
||||
if (['24h', '3d'].includes(this.timespan)) {
|
||||
tooltip += `<small>` + $localize`At block: ${ticks[0].data[2]}` + `</small>`;
|
||||
} else {
|
||||
tooltip += `<small>` + $localize`Around block: ${ticks[0].data[2]}` + `</small>`;
|
||||
for (const tick of ticks) {
|
||||
if (tick.seriesName === 'Total bid boost') {
|
||||
if (tick.data[1] > 10_000_000) {
|
||||
tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1] / 100_000_000, this.locale, '1.0-8')} BTC<br>`;
|
||||
} else {
|
||||
tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')} sats<br>`;
|
||||
}
|
||||
} else if (tick && tick.seriesName === 'Accelerated') {
|
||||
tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}<br>`;
|
||||
}
|
||||
}
|
||||
tooltip += `<small>` + $localize`Around block: ${ticks[0].data[2]}` + `</small>`;
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
|
@ -211,6 +221,17 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
textStyle: {
|
||||
color: 'white',
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#FFB300',
|
||||
},
|
||||
icon: 'roundRect',
|
||||
},
|
||||
{
|
||||
name: 'Accelerated',
|
||||
inactiveColor: 'rgb(110, 112, 121)',
|
||||
textStyle: {
|
||||
color: 'white',
|
||||
},
|
||||
icon: 'roundRect',
|
||||
},
|
||||
],
|
||||
|
|
@ -222,6 +243,13 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
yAxis: data.length === 0 ? undefined : [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Total bid boost',
|
||||
position: 'right',
|
||||
nameTextStyle: {
|
||||
align: 'right',
|
||||
padding: [0, -65, 0, 0],
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
axisLabel: {
|
||||
color: 'rgb(110, 112, 121)',
|
||||
formatter: (val) => {
|
||||
|
|
@ -232,6 +260,20 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
}
|
||||
}
|
||||
},
|
||||
splitLine: null
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Accelerated',
|
||||
position: 'left',
|
||||
axisLabel: {
|
||||
color: 'rgb(110, 112, 121)',
|
||||
},
|
||||
nameTextStyle: {
|
||||
align: 'right',
|
||||
padding: [0, -35, 0, 0],
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dotted',
|
||||
|
|
@ -240,33 +282,28 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
position: 'right',
|
||||
axisLabel: {
|
||||
color: 'rgb(110, 112, 121)',
|
||||
formatter: function(val) {
|
||||
return `${val}`;
|
||||
}.bind(this)
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
series: data.length === 0 ? undefined : [
|
||||
{
|
||||
legendHoverLink: false,
|
||||
zlevel: 1,
|
||||
name: 'Total bid boost',
|
||||
data: data.map(h => {
|
||||
return [h.timestamp * 1000, h.sumBidBoost, h.avgHeight]
|
||||
}),
|
||||
stack: 'Total',
|
||||
type: 'line',
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
},
|
||||
smooth: true,
|
||||
},
|
||||
{
|
||||
name: 'Accelerated',
|
||||
yAxisIndex: 1,
|
||||
data: data.map(h => {
|
||||
return [h.timestamp * 1000, h.count, h.avgHeight]
|
||||
}),
|
||||
type: 'bar',
|
||||
barWidth: '90%',
|
||||
large: true,
|
||||
barMinHeight: 3,
|
||||
},
|
||||
],
|
||||
dataZoom: (this.widget || data.length === 0 )? undefined : [{
|
||||
|
|
@ -299,19 +336,6 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||
|
||||
onChartInit(ec) {
|
||||
this.chartInstance = ec;
|
||||
|
||||
this.chartInstance.on('click', (e) => {
|
||||
this.zone.run(() => {
|
||||
if (['24h', '3d'].includes(this.timespan)) {
|
||||
const url = new RelativeUrlPipe(this.stateService).transform(`/block/${e.data[2]}`);
|
||||
if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) {
|
||||
window.open(url);
|
||||
} else {
|
||||
this.router.navigate([url]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isMobile() {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ export type AccelerationStats = {
|
|||
templateUrl: './acceleration-stats.component.html',
|
||||
styleUrls: ['./acceleration-stats.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class AccelerationStatsComponent implements OnInit, OnChanges {
|
||||
@Input() timespan: '24h' | '3d' | '1w' | '1m' | 'all' = '1w';
|
||||
@Input() timespan: '24h' | '1m' | '1y' | 'all' = '1y';
|
||||
accelerationStats$: Observable<AccelerationStats>;
|
||||
blocksInPeriod: number = 7 * 144;
|
||||
|
||||
|
|
@ -38,15 +39,12 @@ export class AccelerationStatsComponent implements OnInit, OnChanges {
|
|||
case '24h':
|
||||
this.blocksInPeriod = 144;
|
||||
break;
|
||||
case '3d':
|
||||
this.blocksInPeriod = 3 * 144;
|
||||
break;
|
||||
case '1w':
|
||||
this.blocksInPeriod = 7 * 144;
|
||||
break;
|
||||
case '1m':
|
||||
this.blocksInPeriod = 30 * 144;
|
||||
this.blocksInPeriod = 30.5 * 144;
|
||||
break;
|
||||
case '1y':
|
||||
this.blocksInPeriod = 30.5 * 144 * 365;
|
||||
break;
|
||||
case 'all':
|
||||
this.blocksInPeriod = Infinity;
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { MiningService } from '@app/services/mining.service';
|
|||
templateUrl: './accelerations-list.component.html',
|
||||
styleUrls: ['./accelerations-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class AccelerationsListComponent implements OnInit, OnDestroy {
|
||||
@Input() widget: boolean = false;
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@
|
|||
@case ('24h') {
|
||||
<span style="font-size: xx-small" i18n="mining.1-day">(1 day)</span>
|
||||
}
|
||||
@case ('1w') {
|
||||
<span style="font-size: xx-small" i18n="mining.1-week">(1 week)</span>
|
||||
}
|
||||
@case ('1m') {
|
||||
<span style="font-size: xx-small" i18n="mining.1-month">(1 month)</span>
|
||||
}
|
||||
@case ('1y') {
|
||||
<span style="font-size: xx-small" i18n="mining.1-year">(1 year)</span>
|
||||
}
|
||||
@case ('all') {
|
||||
<span style="font-size: xx-small" i18n="mining.all-time">(all time)</span>
|
||||
}
|
||||
|
|
@ -45,12 +45,12 @@
|
|||
<a href="" (click)="setTimespan('24h')" class="toggler-option"
|
||||
[ngClass]="{'inactive': timespan === '24h'}"><small>24h</small></a>
|
||||
<span style="color: #ffffff66; font-size: 8px"> | </span>
|
||||
<a href="" (click)="setTimespan('1w')" class="toggler-option"
|
||||
[ngClass]="{'inactive': timespan === '1w'}"><small>1w</small></a>
|
||||
<span style="color: #ffffff66; font-size: 8px"> | </span>
|
||||
<a href="" (click)="setTimespan('1m')" class="toggler-option"
|
||||
[ngClass]="{'inactive': timespan === '1m'}"><small>1m</small></a>
|
||||
<span style="color: #ffffff66; font-size: 8px"> | </span>
|
||||
<a href="" (click)="setTimespan('1y')" class="toggler-option"
|
||||
[ngClass]="{'inactive': timespan === '1y'}"><small>1y</small></a>
|
||||
<span style="color: #ffffff66; font-size: 8px"> | </span>
|
||||
<a href="" (click)="setTimespan('all')" class="toggler-option"
|
||||
[ngClass]="{'inactive': timespan === 'all'}"><small i18n="all">all</small></a>
|
||||
</div>
|
||||
|
|
@ -79,13 +79,15 @@
|
|||
<div class="col" style="margin-bottom: 1.47rem">
|
||||
<div class="card graph-card">
|
||||
<div class="card-body ps-2 pe-2">
|
||||
<h5 class="card-title" i18n="acceleration.total-bid-boost">Total Bid Boost</h5>
|
||||
<h5 class="card-title" i18n="acceleration.historical-trend">Historical Trend</h5>
|
||||
<div class="mempool-graph">
|
||||
<app-acceleration-fees-graph
|
||||
[height]="graphHeight"
|
||||
[attr.data-cy]="'acceleration-fees'"
|
||||
[widget]=true
|
||||
[period]="timespan"
|
||||
[right]="80"
|
||||
[left]="50"
|
||||
></app-acceleration-fees-graph>
|
||||
</div>
|
||||
<div class="mt-1"><a [attr.data-cy]="'acceleration-fees-view-more'" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ interface AccelerationBlock extends BlockExtended {
|
|||
selector: 'app-accelerator-dashboard',
|
||||
templateUrl: './accelerator-dashboard.component.html',
|
||||
styleUrls: ['./accelerator-dashboard.component.scss'],
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AcceleratorDashboardComponent implements OnInit, OnDestroy {
|
||||
|
|
@ -37,7 +38,7 @@ export class AcceleratorDashboardComponent implements OnInit, OnDestroy {
|
|||
webGlEnabled = true;
|
||||
seen: Set<string> = new Set();
|
||||
firstLoad = true;
|
||||
timespan: '24h' | '3d' | '1w' | '1m' | 'all' = '1w';
|
||||
timespan: '24h' | '1m' | '1y' | 'all' = '1y';
|
||||
|
||||
accelerationDeltaSubscription: Subscription;
|
||||
|
||||
|
|
@ -51,7 +52,6 @@ export class AcceleratorDashboardComponent implements OnInit, OnDestroy {
|
|||
private serviceApiServices: ServicesApiServices,
|
||||
private audioService: AudioService,
|
||||
private stateService: StateService,
|
||||
@Inject(PLATFORM_ID) private platformId: Object,
|
||||
) {
|
||||
this.webGlEnabled = this.stateService.isBrowser && detectWebGL();
|
||||
this.seoService.setTitle($localize`:@@6b867dc61c6a92f3229f1950f9f2d414790cce95:Accelerator Dashboard`);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ function toRGB({r,g,b}): string {
|
|||
templateUrl: './active-acceleration-box.component.html',
|
||||
styleUrls: ['./active-acceleration-box.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ActiveAccelerationBox implements OnChanges {
|
||||
@Input() acceleratedBy?: number[];
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { WebsocketService } from '@app/services/websocket.service';
|
|||
templateUrl: './pending-stats.component.html',
|
||||
styleUrls: ['./pending-stats.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class PendingStatsComponent implements OnInit {
|
||||
@Input() accelerations$: Observable<Acceleration[]>;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue