Merge pull request #5 from Retropex/rebase0.3.2

Rebase to `v0.3.2`
This commit is contained in:
Léo Haf 2025-04-07 10:50:23 +02:00 committed by GitHub
commit 52e1919aba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
147 changed files with 6780 additions and 799 deletions

View File

@ -3,16 +3,19 @@ name: CI Pipeline for the Backend and Frontend
on:
pull_request:
types: [opened, review_requested, synchronize]
push:
branches:
- master
jobs:
backend:
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
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: ["20", "21"]
node: ["22.14.0"]
flavor: ["dev", "prod"]
fail-fast: false
runs-on: "ubuntu-latest"
runs-on: ubuntu-latest
name: Backend (${{ matrix.flavor }}) - node ${{ matrix.node }}
steps:
@ -66,7 +69,7 @@ jobs:
cache:
name: "Cache assets for builds"
runs-on: "ubuntu-latest"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
@ -157,13 +160,13 @@ jobs:
frontend:
needs: cache
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
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: ["20", "21"]
node: ["22.14.0"]
flavor: ["dev", "prod"]
fail-fast: false
runs-on: "ubuntu-latest"
runs-on: ubuntu-latest
name: Frontend (${{ matrix.flavor }}) - node ${{ matrix.node }}
steps:
@ -245,8 +248,8 @@ jobs:
VERBOSE: 1
e2e:
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
runs-on: "ubuntu-latest"
if: "(github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')) || github.event_name == 'push'"
runs-on: ubuntu-latest
needs: frontend
strategy:
fail-fast: false
@ -263,7 +266,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 20
node-version: 22
cache: "npm"
cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
@ -309,7 +312,7 @@ jobs:
tag: ${{ github.event_name }}
working-directory: ${{ matrix.module }}/frontend
build: npm run config:defaults:${{ matrix.module }}
start: npm run start:local-staging
start: npm run start:parameterized
wait-on: "http://localhost:4200"
wait-on-timeout: 120
record: true
@ -334,7 +337,7 @@ jobs:
tag: ${{ github.event_name }}
working-directory: ${{ matrix.module }}/frontend
build: npm run config:defaults:${{ matrix.module }}
start: npm run start:local-staging
start: npm run start:parameterized
wait-on: "http://localhost:4200"
wait-on-timeout: 120
record: true
@ -359,7 +362,7 @@ jobs:
tag: ${{ github.event_name }}
working-directory: ${{ matrix.module }}/frontend
build: npm run config:defaults:mempool
start: npm run start:local-staging
start: npm run start:parameterized
wait-on: "http://localhost:4200"
wait-on-timeout: 120
record: true
@ -375,10 +378,9 @@ jobs:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
validate_docker_json:
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
runs-on: "ubuntu-latest"
if: "(github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')) || github.event_name == 'push'"
runs-on: ubuntu-latest
name: Validate generated backend Docker JSON
steps:

View File

@ -0,0 +1,181 @@
name: Docker - Update latest tag
on:
workflow_dispatch:
inputs:
tag:
description: 'The Docker image tag to pull'
required: true
type: string
jobs:
retag-and-push:
strategy:
matrix:
service:
- frontend
- backend
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
with:
install: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get source image manifest and SHAs
id: source-manifest
run: |
set -e
echo "Fetching source manifest..."
MANIFEST=$(docker manifest inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:${{ github.event.inputs.tag }})
if [ -z "$MANIFEST" ]; then
echo "No manifest found. Assuming single-arch image."
exit 1
fi
echo "Original source manifest:"
echo "$MANIFEST" | jq .
AMD64_SHA=$(echo "$MANIFEST" | jq -r '.manifests[] | select(.platform.architecture=="amd64" and .platform.os=="linux") | .digest')
ARM64_SHA=$(echo "$MANIFEST" | jq -r '.manifests[] | select(.platform.architecture=="arm64" and .platform.os=="linux") | .digest')
if [ -z "$AMD64_SHA" ] || [ -z "$ARM64_SHA" ]; then
echo "Source image is not multi-arch (missing amd64 or arm64)"
exit 1
fi
echo "Source amd64 manifest digest: $AMD64_SHA"
echo "Source arm64 manifest digest: $ARM64_SHA"
echo "amd64_sha=$AMD64_SHA" >> $GITHUB_OUTPUT
echo "arm64_sha=$ARM64_SHA" >> $GITHUB_OUTPUT
- name: Pull and retag architecture-specific images
run: |
set -e
docker buildx inspect --bootstrap
# Remove any existing local images to avoid cache interference
echo "Removing existing local images if they exist..."
docker image rm ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:${{ github.event.inputs.tag }} || true
# Pull amd64 image by digest
echo "Pulling amd64 image by digest..."
docker pull --platform linux/amd64 ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }}
PULLED_AMD64_MANIFEST_DIGEST=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }} --format '{{index .RepoDigests 0}}' | cut -d@ -f2)
PULLED_AMD64_IMAGE_ID=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }} --format '{{.Id}}')
echo "Pulled amd64 manifest digest: $PULLED_AMD64_MANIFEST_DIGEST"
echo "Pulled amd64 image ID (sha256): $PULLED_AMD64_IMAGE_ID"
# Pull arm64 image by digest
echo "Pulling arm64 image by digest..."
docker pull --platform linux/arm64 ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }}
PULLED_ARM64_MANIFEST_DIGEST=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }} --format '{{index .RepoDigests 0}}' | cut -d@ -f2)
PULLED_ARM64_IMAGE_ID=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }} --format '{{.Id}}')
echo "Pulled arm64 manifest digest: $PULLED_ARM64_MANIFEST_DIGEST"
echo "Pulled arm64 image ID (sha256): $PULLED_ARM64_IMAGE_ID"
# Tag the images
docker tag ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }} ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64
docker tag ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }} ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64
# Verify tagged images
TAGGED_AMD64_IMAGE_ID=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64 --format '{{.Id}}')
TAGGED_ARM64_IMAGE_ID=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64 --format '{{.Id}}')
echo "Tagged amd64 image ID (sha256): $TAGGED_AMD64_IMAGE_ID"
echo "Tagged arm64 image ID (sha256): $TAGGED_ARM64_IMAGE_ID"
- name: Push architecture-specific images
run: |
set -e
echo "Pushing amd64 image..."
docker push ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64
PUSHED_AMD64_DIGEST=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64 --format '{{index .RepoDigests 0}}' | cut -d@ -f2)
echo "Pushed amd64 manifest digest (local): $PUSHED_AMD64_DIGEST"
# Fetch manifest from registry after push
echo "Fetching pushed amd64 manifest from registry..."
PUSHED_AMD64_REGISTRY_MANIFEST=$(docker manifest inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64)
PUSHED_AMD64_REGISTRY_DIGEST=$(echo "$PUSHED_AMD64_REGISTRY_MANIFEST" | jq -r '.config.digest')
echo "Pushed amd64 manifest digest (registry): $PUSHED_AMD64_REGISTRY_DIGEST"
echo "Pushing arm64 image..."
docker push ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64
PUSHED_ARM64_DIGEST=$(docker inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64 --format '{{index .RepoDigests 0}}' | cut -d@ -f2)
echo "Pushed arm64 manifest digest (local): $PUSHED_ARM64_DIGEST"
# Fetch manifest from registry after push
echo "Fetching pushed arm64 manifest from registry..."
PUSHED_ARM64_REGISTRY_MANIFEST=$(docker manifest inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64)
PUSHED_ARM64_REGISTRY_DIGEST=$(echo "$PUSHED_ARM64_REGISTRY_MANIFEST" | jq -r '.config.digest')
echo "Pushed arm64 manifest digest (registry): $PUSHED_ARM64_REGISTRY_DIGEST"
- name: Create and push multi-arch manifest with original digests
run: |
set -e
echo "Creating multi-arch manifest with original digests..."
docker manifest create ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest \
${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.amd64_sha }} \
${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}@${{ steps.source-manifest.outputs.arm64_sha }}
echo "Pushing multi-arch manifest..."
docker manifest push ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest
- name: Clean up intermediate tags
if: success()
run: |
docker rmi ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-amd64 || true
docker rmi ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest-arm64 || true
docker rmi ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:${{ github.event.inputs.tag }} || true
- name: Verify final manifest
run: |
set -e
echo "Fetching final generated manifest..."
FINAL_MANIFEST=$(docker manifest inspect ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest)
echo "Generated final manifest:"
echo "$FINAL_MANIFEST" | jq .
FINAL_AMD64_SHA=$(echo "$FINAL_MANIFEST" | jq -r '.manifests[] | select(.platform.architecture=="amd64" and .platform.os=="linux") | .digest')
FINAL_ARM64_SHA=$(echo "$FINAL_MANIFEST" | jq -r '.manifests[] | select(.platform.architecture=="arm64" and .platform.os=="linux") | .digest')
echo "Final amd64 manifest digest: $FINAL_AMD64_SHA"
echo "Final arm64 manifest digest: $FINAL_ARM64_SHA"
# Compare all digests
echo "Comparing digests..."
echo "Source amd64 digest: ${{ steps.source-manifest.outputs.amd64_sha }}"
echo "Pulled amd64 manifest digest: $PULLED_AMD64_MANIFEST_DIGEST"
echo "Pushed amd64 manifest digest (local): $PUSHED_AMD64_DIGEST"
echo "Pushed amd64 manifest digest (registry): $PUSHED_AMD64_REGISTRY_DIGEST"
echo "Final amd64 digest: $FINAL_AMD64_SHA"
echo "Source arm64 digest: ${{ steps.source-manifest.outputs.arm64_sha }}"
echo "Pulled arm64 manifest digest: $PULLED_ARM64_MANIFEST_DIGEST"
echo "Pushed arm64 manifest digest (local): $PUSHED_ARM64_DIGEST"
echo "Pushed arm64 manifest digest (registry): $PUSHED_ARM64_REGISTRY_DIGEST"
echo "Final arm64 digest: $FINAL_ARM64_SHA"
if [ "$FINAL_AMD64_SHA" != "${{ steps.source-manifest.outputs.amd64_sha }}" ] || [ "$FINAL_ARM64_SHA" != "${{ steps.source-manifest.outputs.arm64_sha }}" ]; then
echo "Error: Final manifest SHAs do not match source SHAs"
exit 1
fi
echo "Successfully created multi-arch ${{ secrets.DOCKER_USERNAME }}/${{ matrix.service }}:latest from ${{ github.event.inputs.tag }}"

273
.github/workflows/e2e_parameterized.yml vendored Normal file
View File

@ -0,0 +1,273 @@
name: 'Parameterized e2e tests'
on:
workflow_dispatch:
inputs:
ref:
description: 'Branch name or Pull Request number (e.g., master or 6102)'
required: true
default: 'master'
type: string
mempool_hostname:
description: 'Mempool Hostname'
required: true
default: 'mempool.space'
type: string
liquid_hostname:
description: 'Liquid Hostname'
required: true
default: 'liquid.network'
type: string
jobs:
cache:
name: "Cache assets for builds"
runs-on: ubuntu-latest
steps:
- name: Determine checkout ref
id: determine-ref
run: |
REF_INPUT="${{ github.event.inputs.ref }}"
if [[ "$REF_INPUT" =~ ^[0-9]+$ ]]; then
echo "ref=refs/pull/$REF_INPUT/head" >> $GITHUB_OUTPUT
else
echo "ref=$REF_INPUT" >> $GITHUB_OUTPUT
fi
- name: Checkout
uses: actions/checkout@v3
with:
ref: ${{ steps.determine-ref.outputs.ref }}
path: assets
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 22.14.0
registry-url: "https://registry.npmjs.org"
- name: Install (Prod dependencies only)
run: npm ci --omit=dev --omit=optional
working-directory: assets/frontend
- name: Restore cached mining pool assets
continue-on-error: true
id: cache-mining-pool-restore
uses: actions/cache/restore@v4
with:
path: |
mining-pool-assets.zip
key: mining-pool-assets-cache
- name: Restore promo video assets
continue-on-error: true
id: cache-promo-video-restore
uses: actions/cache/restore@v4
with:
path: |
promo-video-assets.zip
key: promo-video-assets-cache
- name: Unzip assets before building (src/resources)
continue-on-error: true
run: unzip -o mining-pool-assets.zip -d assets/frontend/src/resources/mining-pools
- name: Unzip assets before building (src/resources)
continue-on-error: true
run: unzip -o promo-video-assets.zip -d assets/frontend/src/resources/promo-video
# - name: Unzip assets before building (dist)
# continue-on-error: true
# run: unzip assets.zip -d assets/frontend/dist/mempool/browser/resources
- name: Sync-assets
run: npm run sync-assets-dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MEMPOOL_CDN: 1
VERBOSE: 1
working-directory: assets/frontend
- name: Zip mining-pool assets
run: zip -jrq mining-pool-assets.zip assets/frontend/src/resources/mining-pools/*
- name: Zip promo-video assets
run: zip -jrq promo-video-assets.zip assets/frontend/src/resources/promo-video/*
- name: Upload mining pool assets as artifact
uses: actions/upload-artifact@v4
with:
name: mining-pool-assets
path: mining-pool-assets.zip
- name: Upload promo video assets as artifact
uses: actions/upload-artifact@v4
with:
name: promo-video-assets
path: promo-video-assets.zip
- name: Save mining pool assets cache
id: cache-mining-pool-save
uses: actions/cache/save@v4
with:
path: |
mining-pool-assets.zip
key: mining-pool-assets-cache
- name: Save promo video assets cache
id: cache-promo-video-save
uses: actions/cache/save@v4
with:
path: |
promo-video-assets.zip
key: promo-video-assets-cache
e2e:
runs-on: ubuntu-latest
needs: cache
strategy:
fail-fast: false
matrix:
module: ["mempool", "liquid", "testnet4"]
name: E2E tests for ${{ matrix.module }}
steps:
- name: Determine checkout ref
id: determine-ref
run: |
REF_INPUT="${{ github.event.inputs.ref }}"
if [[ "$REF_INPUT" =~ ^[0-9]+$ ]]; then
echo "ref=refs/pull/$REF_INPUT/head" >> $GITHUB_OUTPUT
else
echo "ref=$REF_INPUT" >> $GITHUB_OUTPUT
fi
- name: Checkout
uses: actions/checkout@v3
with:
ref: ${{ steps.determine-ref.outputs.ref }}
path: ${{ matrix.module }}
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 22.14.0
cache: "npm"
cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
- name: Restore cached mining pool assets
continue-on-error: true
id: cache-mining-pool-restore
uses: actions/cache/restore@v4
with:
path: |
mining-pool-assets.zip
key: mining-pool-assets-cache
- name: Restore cached promo video assets
continue-on-error: true
id: cache-promo-video-restore
uses: actions/cache/restore@v4
with:
path: |
promo-video-assets.zip
key: promo-video-assets-cache
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: mining-pool-assets
- name: Unzip assets before building (src/resources)
run: unzip -o mining-pool-assets.zip -d ${{ matrix.module }}/frontend/src/resources/mining-pools
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: promo-video-assets
- name: Unzip assets before building (src/resources)
run: unzip -o promo-video-assets.zip -d ${{ matrix.module }}/frontend/src/resources/promo-video
# mempool
- name: Chrome browser tests (${{ matrix.module }})
if: ${{ matrix.module == 'mempool' }}
uses: cypress-io/github-action@v5
with:
tag: ${{ github.event_name }}
working-directory: ${{ matrix.module }}/frontend
build: npm run config:defaults:${{ matrix.module }}
start: npm run start:parameterized
wait-on: "http://localhost:4200"
wait-on-timeout: 120
record: true
parallel: true
spec: |
cypress/e2e/mainnet/*.spec.ts
cypress/e2e/signet/*.spec.ts
group: Tests on Chrome (${{ matrix.module }})
browser: "chrome"
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
env:
COMMIT_INFO_MESSAGE: ${{ github.event_name }} (${{ github.sha }}) - ref ${{ github.event.inputs.ref }} - ${{ github.event.inputs.mempool_hostname }} - ${{ github.event.inputs.liquid_hostname }}
MEMPOOL_HOSTNAME: ${{ github.event.inputs.mempool_hostname }}
LIQUID_HOSTNAME: ${{ github.event.inputs.liquid_hostname }}
MEMPOOL_CI_API_KEY: ${{ secrets.MEMPOOL_CI_API_KEY }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
# liquid
- name: Chrome browser tests (${{ matrix.module }})
if: ${{ matrix.module == 'liquid' }}
uses: cypress-io/github-action@v5
with:
tag: ${{ github.event_name }}
working-directory: ${{ matrix.module }}/frontend
build: npm run config:defaults:${{ matrix.module }}
start: npm run start:parameterized
wait-on: "http://localhost:4200"
wait-on-timeout: 120
record: true
parallel: true
spec: |
cypress/e2e/liquid/liquid.spec.ts
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
group: Tests on Chrome (${{ matrix.module }})
browser: "chrome"
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
env:
COMMIT_INFO_MESSAGE: ${{ github.event_name }} (${{ github.sha }}) - ref ${{ github.event.inputs.ref }} - ${{ github.event.inputs.mempool_hostname }} - ${{ github.event.inputs.liquid_hostname }}
MEMPOOL_HOSTNAME: ${{ github.event.inputs.mempool_hostname }}
LIQUID_HOSTNAME: ${{ github.event.inputs.liquid_hostname }}
MEMPOOL_CI_API_KEY: ${{ secrets.MEMPOOL_CI_API_KEY }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
# testnet
- name: Chrome browser tests (${{ matrix.module }})
if: ${{ matrix.module == 'testnet4' }}
uses: cypress-io/github-action@v5
with:
tag: ${{ github.event_name }}
working-directory: ${{ matrix.module }}/frontend
build: npm run config:defaults:mempool
start: npm run start:parameterized
wait-on: "http://localhost:4200"
wait-on-timeout: 120
record: true
parallel: true
spec: |
cypress/e2e/testnet4/*.spec.ts
group: Tests on Chrome (${{ matrix.module }})
browser: "chrome"
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
env:
COMMIT_INFO_MESSAGE: ${{ github.event_name }} (${{ github.sha }}) - ref ${{ github.event.inputs.ref }} - ${{ github.event.inputs.mempool_hostname }} - ${{ github.event.inputs.liquid_hostname }}
MEMPOOL_HOSTNAME: ${{ github.event.inputs.mempool_hostname }}
LIQUID_HOSTNAME: ${{ github.event.inputs.liquid_hostname }}
MEMPOOL_CI_API_KEY: ${{ secrets.MEMPOOL_CI_API_KEY }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}

View File

@ -4,7 +4,7 @@ on: [workflow_dispatch]
jobs:
print-backend-sha:
runs-on: 'ubuntu-latest'
runs-on: ubuntu-latest
name: Get block height
steps:
- name: Checkout

View File

@ -4,7 +4,7 @@ on: [workflow_dispatch]
jobs:
print-backend-sha:
runs-on: 'ubuntu-latest'
runs-on: ubuntu-latest
name: Print backend hashes
steps:
- name: Checkout

View File

@ -10,7 +10,7 @@ on:
type: string
jobs:
print-images-sha:
runs-on: 'ubuntu-latest'
runs-on: ubuntu-latest
name: Print digest for images
steps:
- name: Checkout

View File

@ -2,7 +2,7 @@ name: Docker build on tag
env:
DOCKER_CLI_EXPERIMENTAL: enabled
TAG_FMT: "^refs/tags/(((.?[0-9]+){3,4}))$"
DOCKER_BUILDKIT: 0
DOCKER_BUILDKIT: 1 # Enable BuildKit for better performance
COMPOSE_DOCKER_CLI_BUILD: 0
on:
@ -25,13 +25,12 @@ jobs:
timeout-minutes: 120
name: Build and push to DockerHub
steps:
# Workaround based on JonasAlfredsson/docker-on-tmpfs@v1.0.1
- name: Replace the current swap file
shell: bash
run: |
sudo swapoff /mnt/swapfile
sudo rm -v /mnt/swapfile
sudo fallocate -l 13G /mnt/swapfile
sudo swapoff /mnt/swapfile || true
sudo rm -f /mnt/swapfile
sudo fallocate -l 16G /mnt/swapfile
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
@ -50,7 +49,7 @@ jobs:
echo "Directory '/var/lib/docker' not found"
exit 1
fi
sudo mount -t tmpfs -o size=10G tmpfs /var/lib/docker
sudo mount -t tmpfs -o size=12G tmpfs /var/lib/docker
sudo systemctl restart docker
sudo df -h | grep docker
@ -75,10 +74,16 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: linux/amd64,linux/arm64
id: qemu
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
driver-opts: |
network=host
id: buildx
- name: Available platforms
@ -89,19 +94,19 @@ jobs:
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
key: ${{ runner.os }}-buildx-${{ matrix.service }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
${{ runner.os }}-buildx-${{ matrix.service }}-
- name: Run Docker buildx for ${{ matrix.service }} against tag
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache,mode=max" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG \
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \
--build-context rustgbt=./rust \
--build-context backend=./backend \
--output "type=registry" ./${{ matrix.service }}/ \
--build-arg commitHash=$SHORT_SHA
--output "type=registry,push=true" \
--build-arg commitHash=$SHORT_SHA \
./${{ matrix.service }}/

2
.nvmrc
View File

@ -1 +1 @@
v20.8.0
v22

View File

@ -10,7 +10,7 @@ However, this copyright license does not include an implied right or license
to use any trademarks, service marks, logos, or trade names of Mempool Space K.K.
or any other contributor to The Mempool Open Source Project.
The Mempool Open Source Project®, Mempool Accelerator, Mempool Enterprise®,
The Mempool Open Source Project®, Mempool Accelerator®, Mempool Enterprise®,
Mempool Liquidity™, 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

View File

@ -1,23 +1,23 @@
{
"name": "mempool-backend",
"version": "3.1.0-dev",
"version": "3.2.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "mempool-backend",
"version": "3.1.0-dev",
"version": "3.2.0",
"hasInstallScript": true,
"license": "GNU Affero General Public License v3.0",
"dependencies": {
"@mempool/electrum-client": "1.1.9",
"@types/node": "^18.15.3",
"axios": "1.7.2",
"axios": "1.8.1",
"bitcoinjs-lib": "~6.1.3",
"crypto-js": "~4.2.0",
"express": "~4.21.1",
"maxmind": "~4.3.11",
"mysql2": "~3.12.0",
"mysql2": "~3.13.0",
"redis": "^4.7.0",
"rust-gbt": "file:./rust-gbt",
"socks-proxy-agent": "~7.0.0",
@ -2275,9 +2275,9 @@
}
},
"node_modules/axios": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz",
"integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@ -6173,9 +6173,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/mysql2": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz",
"integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==",
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.13.0.tgz",
"integrity": "sha512-M6DIQjTqKeqXH5HLbLMxwcK5XfXHw30u5ap6EZmu7QVmcF/gnh2wS/EOiQ4MTbXz/vQeoXrmycPlVRM00WSslg==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
@ -9459,9 +9459,9 @@
"integrity": "sha512-+H+kuK34PfMaI9PNU/NSjBKL5hh/KDM9J72kwYeYEm0A8B1AC4fuCy3qsjnA7lxklgyXsB68yn8Z2xoZEjgwCQ=="
},
"axios": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz",
"integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
@ -12337,9 +12337,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"mysql2": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz",
"integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==",
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.13.0.tgz",
"integrity": "sha512-M6DIQjTqKeqXH5HLbLMxwcK5XfXHw30u5ap6EZmu7QVmcF/gnh2wS/EOiQ4MTbXz/vQeoXrmycPlVRM00WSslg==",
"requires": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",

View File

@ -1,6 +1,6 @@
{
"name": "mempool-backend",
"version": "3.1.0-dev",
"version": "3.2.0",
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
"license": "GNU Affero General Public License v3.0",
"homepage": "https://mempool.space",
@ -41,12 +41,12 @@
"dependencies": {
"@mempool/electrum-client": "1.1.9",
"@types/node": "^18.15.3",
"axios": "1.7.2",
"axios": "1.8.1",
"bitcoinjs-lib": "~6.1.3",
"crypto-js": "~4.2.0",
"express": "~4.21.1",
"maxmind": "~4.3.11",
"mysql2": "~3.12.0",
"mysql2": "~3.13.0",
"rust-gbt": "file:./rust-gbt",
"redis": "^4.7.0",
"socks-proxy-agent": "~7.0.0",

View File

@ -55,6 +55,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))
.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
.post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage)
// Internal routes
@ -404,8 +406,8 @@ class BitcoinRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString());
res.json(block);
} catch (e) {
handleError(req, res, 500, 'Failed to get block');
} catch (e: any) {
handleError(req, res, e?.response?.status === 404 ? 404 : 500, 'Failed to get block');
}
}
@ -981,6 +983,92 @@ class BitcoinRoutes {
}
}
private async $getPrevouts(req: Request, res: Response) {
try {
const outpoints = req.body;
if (!Array.isArray(outpoints) || outpoints.some((item) => !/^[a-fA-F0-9]{64}$/.test(item.txid) || typeof item.vout !== 'number')) {
handleError(req, res, 400, 'Invalid outpoints format');
return;
}
if (outpoints.length > 100) {
handleError(req, res, 400, 'Too many outpoints requested');
return;
}
const result = Array(outpoints.length).fill(null);
const memPool = mempool.getMempool();
for (let i = 0; i < outpoints.length; i++) {
const outpoint = outpoints[i];
let prevout: IEsploraApi.Vout | null = null;
let unconfirmed: boolean | null = null;
const mempoolTx = memPool[outpoint.txid];
if (mempoolTx) {
if (outpoint.vout < mempoolTx.vout.length) {
prevout = mempoolTx.vout[outpoint.vout];
unconfirmed = true;
}
} else {
try {
const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false);
if (rawPrevout) {
prevout = {
value: Math.round(rawPrevout.value * 100000000),
scriptpubkey: rawPrevout.scriptPubKey.hex,
scriptpubkey_asm: rawPrevout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(rawPrevout.scriptPubKey.hex) : '',
scriptpubkey_type: transactionUtils.translateScriptPubKeyType(rawPrevout.scriptPubKey.type),
scriptpubkey_address: rawPrevout.scriptPubKey && rawPrevout.scriptPubKey.address ? rawPrevout.scriptPubKey.address : '',
};
unconfirmed = false;
}
} catch (e) {
// Ignore bitcoin client errors, just leave prevout as null
}
}
if (prevout) {
result[i] = { prevout, unconfirmed };
}
}
res.json(result);
} catch (e) {
handleError(req, res, 500, 'Failed to get prevouts');
}
}
private getCpfpLocalTxs(req: Request, res: Response) {
try {
const transactions = req.body;
if (!Array.isArray(transactions) || transactions.some(tx =>
!tx || typeof tx !== 'object' ||
!/^[a-fA-F0-9]{64}$/.test(tx.txid) ||
typeof tx.weight !== 'number' ||
typeof tx.sigops !== 'number' ||
typeof tx.fee !== 'number' ||
!Array.isArray(tx.vin) ||
!Array.isArray(tx.vout)
)) {
handleError(req, res, 400, 'Invalid transactions format');
return;
}
if (transactions.length > 1) {
handleError(req, res, 400, 'More than one transaction is not supported yet');
return;
}
const cpfpInfo = calculateMempoolTxCpfp(transactions[0], mempool.getMempool(), true);
res.json([cpfpInfo]);
} catch (e) {
handleError(req, res, 500, 'Failed to calculate CPFP info');
}
}
}
export default new BitcoinRoutes();

View File

@ -36,7 +36,7 @@ class FailoverRouter {
maxHeight: number = 0;
hosts: FailoverHost[];
multihost: boolean;
gitHashInterval: number = 600000; // 10 minutes
gitHashInterval: number = 60000; // 1 minute
pollInterval: number = 60000; // 1 minute
pollTimer: NodeJS.Timeout | null = null;
pollConnection = axios.create();
@ -111,7 +111,7 @@ class FailoverRouter {
for (const host of this.hosts) {
try {
const result = await (host.socket
? this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: config.ESPLORA.FALLBACK_TIMEOUT })
? this.pollConnection.get<number>('http://api/blocks/tip/height', { socketPath: host.host, timeout: config.ESPLORA.FALLBACK_TIMEOUT })
: this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: config.ESPLORA.FALLBACK_TIMEOUT })
);
if (result) {
@ -288,7 +288,7 @@ class FailoverRouter {
let url;
if (host.socket) {
axiosConfig = { socketPath: host.host, timeout: config.ESPLORA.REQUEST_TIMEOUT, responseType };
url = path;
url = 'http://api' + path;
} else {
axiosConfig = { timeout: config.ESPLORA.REQUEST_TIMEOUT, responseType };
url = host.host + path;

View File

@ -1391,7 +1391,7 @@ class Blocks {
}
public async $getBlockAuditSummary(hash: string): Promise<BlockAudit | null> {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && Common.auditIndexingEnabled()) {
return BlocksAuditsRepository.$getBlockAudit(hash);
} else {
return null;
@ -1399,7 +1399,7 @@ class Blocks {
}
public async $getBlockTxAuditSummary(hash: string, txid: string): Promise<TransactionAudit | null> {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && Common.auditIndexingEnabled()) {
return BlocksAuditsRepository.$getBlockTxAudit(hash, txid);
} else {
return null;
@ -1469,11 +1469,11 @@ class Blocks {
if (rows && Array.isArray(rows)) {
return rows.map(r => r.definition_hash);
} else {
logger.debug(`Unable to retreive list of blocks.definition_hash from db (no result)`);
logger.debug(`Unable to retrieve list of blocks.definition_hash from db (no result)`);
return null;
}
} catch (e) {
logger.debug(`Unable to retreive list of blocks.definition_hash from db (exception: ${e})`);
logger.debug(`Unable to retrieve list of blocks.definition_hash from db (exception: ${e})`);
return null;
}
}
@ -1484,11 +1484,11 @@ class Blocks {
if (rows && Array.isArray(rows)) {
return rows.map(r => r.hash);
} else {
logger.debug(`Unable to retreive list of blocks for definition hash ${definitionHash} from db (no result)`);
logger.debug(`Unable to retrieve list of blocks for definition hash ${definitionHash} from db (no result)`);
return null;
}
} catch (e) {
logger.debug(`Unable to retreive list of blocks for definition hash ${definitionHash} from db (exception: ${e})`);
logger.debug(`Unable to retrieve list of blocks for definition hash ${definitionHash} from db (exception: ${e})`);
return null;
}
}

View File

@ -739,6 +739,13 @@ export class Common {
);
}
static auditIndexingEnabled(): boolean {
return (
Common.indexingEnabled() &&
config.MEMPOOL.AUDIT === true
);
}
static gogglesIndexingEnabled(): boolean {
return (
Common.blocksSummariesIndexingEnabled() &&

View File

@ -167,8 +167,10 @@ export function calculateGoodBlockCpfp(height: number, transactions: MempoolTran
/**
* Takes a mempool transaction and a copy of the current mempool, and calculates the CPFP data for
* that transaction (and all others in the same cluster)
* If the passed transaction is not guaranteed to be in the mempool, set localTx to true: this will
* prevent updating the CPFP data of other transactions in the cluster
*/
export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo {
export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }, localTx: boolean = false): CpfpInfo {
if (tx.cpfpUpdated && Date.now() < (tx.cpfpUpdated + CPFP_UPDATE_INTERVAL)) {
tx.cpfpDirty = false;
return {
@ -198,17 +200,26 @@ export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool:
totalFee += tx.fees.base;
}
const effectiveFeePerVsize = totalFee / totalVsize;
for (const tx of cluster.values()) {
mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
mempool[tx.txid].bestDescendant = null;
mempool[tx.txid].cpfpChecked = true;
mempool[tx.txid].cpfpDirty = true;
mempool[tx.txid].cpfpUpdated = Date.now();
}
tx = mempool[tx.txid];
if (localTx) {
tx.effectiveFeePerVsize = effectiveFeePerVsize;
tx.ancestors = Array.from(cluster.get(tx.txid)?.ancestors.values() || []).map(ancestor => ({ txid: ancestor.txid, weight: ancestor.weight, fee: ancestor.fees.base }));
tx.descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !cluster.get(tx.txid)?.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
tx.bestDescendant = null;
} else {
for (const tx of cluster.values()) {
mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
mempool[tx.txid].bestDescendant = null;
mempool[tx.txid].cpfpChecked = true;
mempool[tx.txid].cpfpDirty = true;
mempool[tx.txid].cpfpUpdated = Date.now();
}
tx = mempool[tx.txid];
}
return {
ancestors: tx.ancestors || [],

View File

@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2';
class DatabaseMigration {
private static currentVersion = 95;
private static currentVersion = 96;
private queryTimeout = 3600_000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@ -1130,6 +1130,11 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE blocks ADD INDEX `definition_hash` (`definition_hash`)');
await this.updateToSchemaVersion(95);
}
if (databaseSchemaVersion < 96) {
await this.$executeQuery(`ALTER TABLE blocks_audits MODIFY time timestamp NOT NULL DEFAULT 0`);
await this.updateToSchemaVersion(96);
}
}
/**

View File

@ -8,6 +8,7 @@ import mining from './mining/mining';
import transactionUtils from './transaction-utils';
import BlocksRepository from '../repositories/BlocksRepository';
import redisCache from './redis-cache';
import blocks from './blocks';
class PoolsParser {
miningPools: any[] = [];
@ -42,6 +43,8 @@ class PoolsParser {
await this.$insertUnknownPool();
let reindexUnknown = false;
let clearCache = false;
for (const pool of this.miningPools) {
if (!pool.id) {
@ -78,17 +81,20 @@ class PoolsParser {
logger.debug(`Inserting new mining pool ${pool.name}`);
await PoolsRepository.$insertNewMiningPool(pool, slug);
reindexUnknown = true;
clearCache = true;
} else {
if (poolDB.name !== pool.name) {
// Pool has been renamed
const newSlug = pool.name.replace(/[^a-z0-9]/gi, '').toLowerCase();
logger.warn(`Renaming ${poolDB.name} mining pool to ${pool.name}. Slug has been updated. Maybe you want to make a redirection from 'https://mempool.space/mining/pool/${poolDB.slug}' to 'https://mempool.space/mining/pool/${newSlug}`);
await PoolsRepository.$renameMiningPool(poolDB.id, newSlug, pool.name);
clearCache = true;
}
if (poolDB.link !== pool.link) {
// Pool link has changed
logger.debug(`Updating link for ${pool.name} mining pool`);
await PoolsRepository.$updateMiningPoolLink(poolDB.id, pool.link);
clearCache = true;
}
if (JSON.stringify(pool.addresses) !== poolDB.addresses ||
JSON.stringify(pool.regexes) !== poolDB.regexes) {
@ -96,6 +102,7 @@ class PoolsParser {
logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool.`);
await PoolsRepository.$updateMiningPoolTags(poolDB.id, pool.addresses, pool.regexes);
reindexUnknown = true;
clearCache = true;
await this.$reindexBlocksForPool(poolDB.id);
}
}
@ -111,6 +118,19 @@ class PoolsParser {
}
await this.$reindexBlocksForPool(unknownPool.id);
}
// 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;
}
}
// update persistent cache with the reindexed data
diskCache.$saveCacheToDisk();
redisCache.$updateBlocks(blocks.getBlocks());
}
}
public matchBlockMiner(scriptsig: string, addresses: string[], pools: PoolTag[]): PoolTag | undefined {

View File

@ -30,6 +30,7 @@ const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes
class WalletApi {
private wallets: Record<string, Wallet> = {};
private syncing = false;
private lastSync = 0;
constructor() {
this.wallets = config.WALLETS.ENABLED ? (config.WALLETS.WALLETS as string[]).reduce((acc, wallet) => {
@ -47,7 +48,38 @@ class WalletApi {
if (!config.WALLETS.ENABLED || this.syncing) {
return;
}
this.syncing = true;
if (config.WALLETS.AUTO && (Date.now() - this.lastSync) > POLL_FREQUENCY) {
try {
// update list of active wallets
this.lastSync = Date.now();
const response = await axios.get(config.MEMPOOL_SERVICES.API + `/wallets`);
const walletList: string[] = response.data;
if (walletList) {
// create a quick lookup dictionary of active wallets
const newWallets: Record<string, boolean> = Object.fromEntries(
walletList.map(wallet => [wallet, true])
);
for (const wallet of walletList) {
// don't overwrite existing wallets
if (!this.wallets[wallet]) {
this.wallets[wallet] = { name: wallet, addresses: {}, lastPoll: 0 };
}
}
// remove wallets that are no longer active
for (const wallet of Object.keys(this.wallets)) {
if (!newWallets[wallet]) {
delete this.wallets[wallet];
}
}
}
} catch (e) {
logger.err(`Error updating active wallets: ${(e instanceof Error ? e.message : e)}`);
}
}
for (const walletKey of Object.keys(this.wallets)) {
const wallet = this.wallets[walletKey];
if (wallet.lastPoll < (Date.now() - POLL_FREQUENCY)) {
@ -72,6 +104,7 @@ class WalletApi {
}
}
}
this.syncing = false;
}

View File

@ -420,6 +420,29 @@ class TransactionUtils {
return { prioritized, deprioritized };
}
// Copied from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/bitcoin/bitcoin-api.ts#L324
public translateScriptPubKeyType(outputType: string): string {
const map = {
'pubkey': 'p2pk',
'pubkeyhash': 'p2pkh',
'scripthash': 'p2sh',
'witness_v0_keyhash': 'v0_p2wpkh',
'witness_v0_scripthash': 'v0_p2wsh',
'witness_v1_taproot': 'v1_p2tr',
'nonstandard': 'nonstandard',
'multisig': 'multisig',
'anchor': 'anchor',
'nulldata': 'op_return'
};
if (map[outputType]) {
return map[outputType];
} else {
return 'unknown';
}
}
}
export default new TransactionUtils();

View File

@ -1011,15 +1011,19 @@ class WebsocketHandler {
const blockTransactions = structuredClone(transactions);
this.printLogs();
await statistics.runStatistics();
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) {
await statistics.runStatistics();
}
const _memPool = memPool.getMempool();
const candidateTxs = await 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);
const accelerations = Object.values(mempool.getAccelerations());
await accelerationRepository.$indexAccelerationsForBlock(block, accelerations, structuredClone(transactions));
if (config.DATABASE.ENABLED) {
const accelerations = Object.values(mempool.getAccelerations());
await accelerationRepository.$indexAccelerationsForBlock(block, accelerations, structuredClone(transactions));
}
const rbfTransactions = Common.findMinedRbfTransactions(transactions, memPool.getSpendMap());
memPool.handleRbfTransactions(rbfTransactions);
@ -1095,7 +1099,9 @@ class WebsocketHandler {
if (config.CORE_RPC.DEBUG_LOG_PATH && block.extras) {
const firstSeen = getRecentFirstSeen(block.id);
if (firstSeen) {
BlocksRepository.$saveFirstSeenTime(block.id, firstSeen);
if (config.DATABASE.ENABLED) {
BlocksRepository.$saveFirstSeenTime(block.id, firstSeen);
}
block.extras.firstSeen = firstSeen;
}
}
@ -1392,7 +1398,9 @@ class WebsocketHandler {
});
}
await statistics.runStatistics();
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) {
await statistics.runStatistics();
}
}
public handleNewStratumJob(job: StratumJob): void {

View File

@ -164,6 +164,7 @@ interface IConfig {
},
WALLETS: {
ENABLED: boolean;
AUTO: boolean;
WALLETS: string[];
},
STRATUM: {
@ -334,6 +335,7 @@ const defaults: IConfig = {
},
'WALLETS': {
'ENABLED': false,
'AUTO': false,
'WALLETS': [],
},
'STRATUM': {

View File

@ -131,6 +131,9 @@ class Server {
this.app
.use((req: Request, res: Response, next: NextFunction) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With');
res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count,X-Mempool-Auth');
next();
})
.use(express.urlencoded({ extended: true }))
@ -153,7 +156,9 @@ class Server {
await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it
await syncAssets.syncAssets$();
await mempoolBlocks.updatePools$();
if (config.DATABASE.ENABLED) {
await mempoolBlocks.updatePools$();
}
if (config.MEMPOOL.ENABLED) {
if (config.MEMPOOL.CACHE_ENABLED) {
await diskCache.$loadMempoolCache();

View File

@ -93,7 +93,7 @@ class HashratesRepository {
const [rows]: any[] = await DB.query(query);
return rows.map(row => row.timestamp);
} catch (e) {
logger.err('Cannot retreive indexed weekly hashrate timestamps. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
logger.err('Cannot retrieve indexed weekly hashrate timestamps. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
throw e;
}
}

View File

@ -98,7 +98,8 @@ class PoolsUpdater {
logger.info(`Mining pools-v2.json (${githubSha}) import completed`, this.tag);
} catch (e) {
this.lastRun = now - 600; // Try again in 10 minutes
// fast-forward lastRun to 10 minutes before the next scheduled update
this.lastRun = now - Math.max(config.MEMPOOL.POOLS_UPDATE_DELAY - 600, 600);
logger.err(`PoolsUpdater failed. Will try again in 10 minutes. Exception: ${JSON.stringify(e)}`, this.tag);
}
}

View File

@ -1,20 +1,20 @@
FROM node:20.15.0-buster-slim AS builder
FROM rust:1.84-bookworm AS builder
ARG commitHash
ENV MEMPOOL_COMMIT_HASH=${commitHash}
WORKDIR /build
RUN apt-get update && \
apt-get install -y curl ca-certificates && \
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs=22.14.0-1nodesource1 build-essential python3 pkg-config && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY . .
RUN apt-get update
RUN apt-get install -y build-essential python3 pkg-config curl ca-certificates
# Install Rust via rustup
RUN CPU_ARCH=$(uname -m); if [ "$CPU_ARCH" = "armv7l" ]; then c_rehash; fi
#RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable
#Workaround to run on github actions from https://github.com/rust-lang/rustup/issues/2700#issuecomment-1367488985
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sed 's#/proc/self/exe#\/bin\/sh#g' | sh -s -- -y --default-toolchain stable
ENV PATH="/root/.cargo/bin:$PATH"
ENV PATH="/usr/local/cargo/bin:$PATH"
COPY --from=backend . .
COPY --from=rustgbt . ../rust/
@ -24,7 +24,14 @@ RUN npm install --omit=dev --omit=optional
WORKDIR /build
RUN npm run package
FROM node:20.15.0-buster-slim
FROM rust:1.84-bookworm AS runtime
RUN apt-get update && \
apt-get install -y curl ca-certificates && \
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs=22.14.0-1nodesource1 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /backend

View File

@ -26,7 +26,7 @@ __MEMPOOL_EXTERNAL_MAX_RETRY__=${MEMPOOL_EXTERNAL_MAX_RETRY:=1}
__MEMPOOL_EXTERNAL_RETRY_INTERVAL__=${MEMPOOL_EXTERNAL_RETRY_INTERVAL:=0}
__MEMPOOL_USER_AGENT__=${MEMPOOL_USER_AGENT:=mempool}
__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info}
__MEMPOOL_AUTOMATIC_POOLS_UPDATE__=${MEMPOOL_AUTOMATIC_POOLS_UPDATE:=false}
__MEMPOOL_AUTOMATIC_POOLS_UPDATE__=${MEMPOOL_AUTOMATIC_POOLS_UPDATE:=true}
__MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json}
__MEMPOOL_POOLS_JSON_TREE_URL__=${MEMPOOL_POOLS_JSON_TREE_URL:=https://api.github.com/repos/mempool/mining-pools/git/trees/master}
__MEMPOOL_POOLS_UPDATE_DELAY__=${MEMPOOL_POOLS_UPDATE_DELAY:=604800}

View File

@ -1,4 +1,4 @@
FROM node:20.15.0-buster-slim AS builder
FROM node:22.14.0-bookworm-slim AS builder
ARG commitHash
ENV DOCKER_COMMIT_HASH=${commitHash}

View File

@ -261,20 +261,14 @@
"proxyConfig": "proxy.conf.mixed.js",
"verbose": true
},
"staging": {
"proxyConfig": "proxy.conf.js",
"disableHostCheck": true,
"host": "0.0.0.0",
"verbose": true
},
"local-prod": {
"proxyConfig": "proxy.conf.js",
"disableHostCheck": true,
"host": "0.0.0.0",
"verbose": false
},
"local-staging": {
"proxyConfig": "proxy.conf.staging.js",
"parameterized": {
"proxyConfig": "proxy.conf.parameterized.js",
"disableHostCheck": true,
"host": "0.0.0.0",
"verbose": false

View File

@ -16,10 +16,10 @@
"mobileOrder": 4
},
{
"component": "balance",
"component": "walletBalance",
"mobileOrder": 1,
"props": {
"address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo"
"wallet": "ONBTC"
}
},
{
@ -30,21 +30,22 @@
}
},
{
"component": "address",
"component": "wallet",
"mobileOrder": 2,
"props": {
"address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo",
"period": "1m"
"wallet": "ONBTC",
"period": "1m",
"label": "bitcoin.gob.sv"
}
},
{
"component": "blocks"
},
{
"component": "addressTransactions",
"component": "walletTransactions",
"mobileOrder": 3,
"props": {
"address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo"
"wallet": "ONBTC"
}
}
]

View File

@ -57,11 +57,6 @@ describe('Liquid', () => {
});
});
it('loads the tv page - desktop', () => {
cy.visit(`${basePath}/tv`);
cy.waitForSkeletonGone();
});
it('loads the graphs page - mobile', () => {
cy.visit(`${basePath}`)
cy.waitForSkeletonGone();

View File

@ -57,11 +57,6 @@ describe('Liquid Testnet', () => {
cy.waitForSkeletonGone();
});
it('loads the tv page - desktop', () => {
cy.visit(`${basePath}/tv`);
cy.waitForSkeletonGone();
});
it('loads the graphs page - mobile', () => {
cy.visit(`${basePath}`)
cy.waitForSkeletonGone();

View File

@ -1,4 +1,4 @@
import { emitMempoolInfo, dropWebSocket } from '../../support/websocket';
import { emitMempoolInfo, dropWebSocket, receiveWebSocketMessageFromServer } from '../../support/websocket';
const baseModule = Cypress.env('BASE_MODULE');
@ -216,6 +216,69 @@ describe('Mainnet', () => {
cy.get('[data-cy="tx-1"] .table-tx-vout .highlight').its('length').should('equal', 2);
cy.get('[data-cy="tx-1"] .table-tx-vout .highlight').invoke('text').should('contain', `${address}`);
});
describe('address poisoning', () => {
it('highlights potential address poisoning attacks on outputs, prefix and infix', () => {
const txid = '152a5dea805f95d6f83e50a9fd082630f542a52a076ebabdb295723eaf53fa30';
const prefix = '1DonatePLease';
const infix1 = 'SenderAddressXVXCmAY';
const infix2 = '5btcToSenderXXWBoKhB';
cy.visit(`/tx/${txid}`);
cy.waitForSkeletonGone();
cy.get('.alert-mempool').should('exist');
cy.get('.poison-alert').its('length').should('equal', 2);
cy.get('.prefix')
.should('have.length', 2)
.each(($el) => {
cy.wrap($el).should('have.text', prefix);
});
cy.get('.infix')
.should('have.length', 2)
.then(($infixes) => {
cy.wrap($infixes[0]).should('have.text', infix1);
cy.wrap($infixes[1]).should('have.text', infix2);
});
});
it('highlights potential address poisoning attacks on inputs and outputs, prefix, infix and postfix', () => {
const txid = '44544516084ea916ff1eb69c675c693e252addbbaf77102ffff86e3979ac6132';
const prefix = 'bc1qge8';
const infix1 = '6gqjnk8aqs3nvv7ejrvcd4zq6qur3';
const infix2 = 'xyxjex6zzzx5g8hh65vsel4e548p2';
const postfix1 = '6p6e3r';
const postfix2 = '6p6e3r';
cy.visit(`/tx/${txid}`);
cy.waitForSkeletonGone();
cy.get('.alert-mempool').should('exist');
cy.get('.poison-alert').its('length').should('equal', 2);
cy.get('.prefix')
.should('have.length', 2)
.each(($el) => {
cy.wrap($el).should('have.text', prefix);
});
cy.get('.infix')
.should('have.length', 2)
.then(($infixes) => {
cy.wrap($infixes[0]).should('have.text', infix1);
cy.wrap($infixes[1]).should('have.text', infix2);
});
cy.get('.postfix')
.should('have.length', 2)
.then(($postfixes) => {
cy.wrap($postfixes[0]).should('include.text', postfix1);
cy.wrap($postfixes[1]).should('include.text', postfix2);
});
});
});
});
describe('blocks navigation', () => {
@ -397,6 +460,7 @@ describe('Mainnet', () => {
cy.get('#dropdownFees').should('be.visible');
cy.get('.btn-group').should('be.visible');
});
it('check buttons - tablet', () => {
cy.viewport('ipad-2');
cy.visit('/graphs');
@ -405,6 +469,7 @@ describe('Mainnet', () => {
cy.get('#dropdownFees').should('be.visible');
cy.get('.btn-group').should('be.visible');
});
it('check buttons - desktop', () => {
cy.viewport('macbook-16');
cy.visit('/graphs');
@ -415,26 +480,6 @@ describe('Mainnet', () => {
});
});
it('loads the tv screen - desktop', () => {
cy.viewport('macbook-16');
cy.visit('/graphs/mempool');
cy.waitForSkeletonGone();
cy.get('#btn-tv').click().then(() => {
cy.viewport('macbook-16');
cy.get('.chart-holder');
cy.get('.blockchain-wrapper').should('be.visible');
cy.get('#mempool-block-0').should('be.visible');
});
});
it('loads the tv screen - mobile', () => {
cy.viewport('iphone-6');
cy.visit('/tv');
cy.waitForSkeletonGone();
cy.get('.chart-holder');
cy.get('.blockchain-wrapper').should('not.visible');
});
it('loads the api screen', () => {
cy.visit('/');
cy.waitForSkeletonGone();
@ -516,7 +561,44 @@ describe('Mainnet', () => {
});
describe('RBF transactions', () => {
it('shows RBF transactions properly (mobile)', () => {
it('RBF page gets updated over websockets', () => {
cy.intercept('/api/v1/replacements', {
statusCode: 200,
body: []
});
cy.intercept('/api/v1/fullrbf/replacements', {
statusCode: 200,
body: []
});
cy.mockMempoolSocketV2();
cy.visit('/rbf');
cy.get('.no-replacements');
cy.get('.tree').should('have.length', 0);
receiveWebSocketMessageFromServer({
params: {
file: {
path: 'rbf_page/rbf_01.json'
}
}
});
cy.get('.tree').should('have.length', 1);
receiveWebSocketMessageFromServer({
params: {
file: {
path: 'rbf_page/rbf_02.json'
}
}
});
cy.get('.tree').should('have.length', 2);
});
it('shows RBF transactions properly (mobile - details)', () => {
cy.intercept('/api/v1/tx/21518a98d1aa9df524865d2f88c578499f524eb1d0c4d3e70312ab863508692f/cached', {
fixture: 'mainnet_tx_cached.json'
}).as('cached_tx');
@ -527,7 +609,7 @@ describe('Mainnet', () => {
cy.viewport('iphone-xr');
cy.mockMempoolSocket();
cy.visit('/tx/21518a98d1aa9df524865d2f88c578499f524eb1d0c4d3e70312ab863508692f');
cy.visit('/tx/21518a98d1aa9df524865d2f88c578499f524eb1d0c4d3e70312ab863508692f?mode=details');
cy.waitForSkeletonGone();
@ -545,7 +627,120 @@ describe('Mainnet', () => {
}
});
cy.get('.alert-mempool').should('be.visible');
});
it('shows RBF transactions properly (mobile - tracker)', () => {
cy.mockMempoolSocketV2();
cy.viewport('iphone-xr');
// API Mocks
cy.intercept('/api/v1/mining/pools/1w', {
fixture: 'details_rbf/api_mining_pools_1w.json'
}).as('api_mining_1w');
cy.intercept('/api/tx/242f3fff9ca7d5aea7a7a57d886f3fa7329e24fac948598a991b3a3dd631cd29', {
statusCode: 404,
body: 'Transaction not found'
}).as('api_tx01_404');
cy.intercept('/api/v1/tx/242f3fff9ca7d5aea7a7a57d886f3fa7329e24fac948598a991b3a3dd631cd29/cached', {
fixture: 'details_rbf/tx01_api_cached.json'
}).as('api_tx01_cached');
cy.intercept('/api/v1/tx/242f3fff9ca7d5aea7a7a57d886f3fa7329e24fac948598a991b3a3dd631cd29/rbf', {
fixture: 'details_rbf/tx01_api_rbf.json'
}).as('api_tx01_rbf');
cy.visit('/tx/242f3fff9ca7d5aea7a7a57d886f3fa7329e24fac948598a991b3a3dd631cd29?mode=tracker');
cy.wait('@api_tx01_rbf');
// Start sending mocked WS messages
receiveWebSocketMessageFromServer({
params: {
file: {
path: 'details_rbf/tx01_ws_stratum_jobs.json'
}
}
});
receiveWebSocketMessageFromServer({
params: {
file: {
path: 'details_rbf/tx01_ws_blocks_01.json'
}
}
});
receiveWebSocketMessageFromServer({
params: {
file: {
path: 'details_rbf/tx01_ws_tx_replaced.json'
}
}
});
receiveWebSocketMessageFromServer({
params: {
file: {
path: 'details_rbf/tx01_ws_mempool_blocks_01.json'
}
}
});
cy.get('.alert-replaced').should('be.visible');
cy.get('.explainer').should('be.visible');
cy.get('svg[data-icon=timeline]').should('be.visible');
// Second TX setup
cy.intercept('/api/tx/b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698', {
fixture: 'details_rbf/tx02_api_tx.json'
}).as('tx02_api');
cy.intercept('/api/v1/transaction-times?txId%5B%5D=b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698', {
fixture: 'details_rbf/tx02_api_tx_times.json'
}).as('tx02_api_tx_times');
cy.intercept('/api/v1/tx/b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698/rbf', {
fixture: 'details_rbf/tx02_api_rbf.json'
}).as('tx02_api_rbf');
cy.intercept('/api/v1/cpfp/b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698', {
fixture: 'details_rbf/tx02_api_cpfp.json'
}).as('tx02_api_cpfp');
// Go to the replacement tx
cy.get('.alert-replaced a').click();
cy.wait('@tx02_api_cpfp');
receiveWebSocketMessageFromServer({
params: {
file: {
path: 'details_rbf/tx02_ws_tx_position.json'
}
}
});
receiveWebSocketMessageFromServer({
params: {
file: {
path: 'details_rbf/tx02_ws_mempool_blocks_01.json'
}
}
});
cy.get('svg[data-icon=hourglass-half]').should('be.visible');
receiveWebSocketMessageFromServer({
params: {
file: {
path: 'details_rbf/tx02_ws_block.json'
}
}
});
cy.get('app-confirmations');
cy.get('svg[data-icon=circle-check]').should('be.visible');
});
it('shows RBF transactions properly (desktop)', () => {

View File

@ -60,30 +60,6 @@ describe('Signet', () => {
});
});
describe.skip('tv mode', () => {
it('loads the tv screen - desktop', () => {
cy.viewport('macbook-16');
cy.visit('/signet/graphs');
cy.waitForSkeletonGone();
cy.get('#btn-tv').click().then(() => {
cy.get('.chart-holder').should('be.visible');
cy.get('#mempool-block-0').should('be.visible');
cy.get('.tv-only').should('not.exist');
});
});
it('loads the tv screen - mobile', () => {
cy.visit('/signet/graphs');
cy.waitForSkeletonGone();
cy.get('#btn-tv').click().then(() => {
cy.viewport('iphone-8');
cy.get('.chart-holder').should('be.visible');
cy.get('.tv-only').should('not.exist');
cy.get('#mempool-block-0').should('be.visible');
});
});
});
it('loads the api screen', () => {
cy.visit('/signet');
cy.waitForSkeletonGone();

View File

@ -60,30 +60,6 @@ describe('Testnet4', () => {
});
});
describe('tv mode', () => {
it('loads the tv screen - desktop', () => {
cy.viewport('macbook-16');
cy.visit('/testnet4/graphs');
cy.waitForSkeletonGone();
cy.get('#btn-tv').click().then(() => {
cy.wait(1000);
cy.get('.tv-only').should('not.exist');
cy.get('#mempool-block-0').should('be.visible');
});
});
it('loads the tv screen - mobile', () => {
cy.visit('/testnet4/graphs');
cy.waitForSkeletonGone();
cy.get('#btn-tv').click().then(() => {
cy.viewport('iphone-6');
cy.wait(1000);
cy.get('.tv-only').should('not.exist');
});
});
});
it('loads the api screen', () => {
cy.visit('/testnet4');
cy.waitForSkeletonGone();

View File

@ -0,0 +1,60 @@
{
"txSummary": {
"txid": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698",
"effectiveVsize": 224,
"effectiveFee": 960,
"ancestorCount": 1
},
"cost": 1000,
"targetFeeRate": 3,
"nextBlockFee": 672,
"userBalance": 0,
"mempoolBaseFee": 50000,
"vsizeFee": 0,
"pools": [
36,
102,
112,
44,
4,
2,
6,
94,
143,
43,
105,
115,
142,
111
],
"options": [
{
"fee": 1000
},
{
"fee": 2000
},
{
"fee": 10000
}
],
"hasAccess": false,
"availablePaymentMethods": {
"bitcoin": {
"enabled": true,
"min": 1000,
"max": 10000000
},
"applePay": {
"enabled": true,
"min": 10,
"max": 1000
},
"googlePay": {
"enabled": true,
"min": 10,
"max": 1000
}
},
"unavailable": false
}

View File

@ -0,0 +1,3 @@
{
"gitCommit": "62f80296"
}

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1,260 @@
{
"pools": [
{
"poolId": 112,
"name": "Foundry USA",
"link": "https://foundrydigital.com",
"blockCount": 323,
"rank": 1,
"emptyBlocks": 0,
"slug": "foundryusa",
"avgMatchRate": 99.96,
"avgFeeDelta": "-0.01971455",
"poolUniqueId": 111
},
{
"poolId": 45,
"name": "AntPool",
"link": "https://www.antpool.com",
"blockCount": 171,
"rank": 2,
"emptyBlocks": 0,
"slug": "antpool",
"avgMatchRate": 99.99,
"avgFeeDelta": "-0.04227368",
"poolUniqueId": 44
},
{
"poolId": 74,
"name": "ViaBTC",
"link": "https://viabtc.com",
"blockCount": 166,
"rank": 3,
"emptyBlocks": 0,
"slug": "viabtc",
"avgMatchRate": 99.99,
"avgFeeDelta": "-0.02530964",
"poolUniqueId": 73
},
{
"poolId": 37,
"name": "F2Pool",
"link": "https://www.f2pool.com",
"blockCount": 104,
"rank": 4,
"emptyBlocks": 0,
"slug": "f2pool",
"avgMatchRate": 99.99,
"avgFeeDelta": "-0.03299327",
"poolUniqueId": 36
},
{
"poolId": 116,
"name": "MARA Pool",
"link": "https://marapool.com",
"blockCount": 66,
"rank": 5,
"emptyBlocks": 0,
"slug": "marapool",
"avgMatchRate": 99.97,
"avgFeeDelta": "0.02366061",
"poolUniqueId": 115
},
{
"poolId": 103,
"name": "SpiderPool",
"link": "https://www.spiderpool.com",
"blockCount": 46,
"rank": 6,
"emptyBlocks": 1,
"slug": "spiderpool",
"avgMatchRate": 97.82,
"avgFeeDelta": "-0.07258913",
"poolUniqueId": 102
},
{
"poolId": 142,
"name": "SECPOOL",
"link": "https://www.secpool.com",
"blockCount": 30,
"rank": 7,
"emptyBlocks": 1,
"slug": "secpool",
"avgMatchRate": 96.67,
"avgFeeDelta": "-0.06596000",
"poolUniqueId": 141
},
{
"poolId": 106,
"name": "Binance Pool",
"link": "https://pool.binance.com",
"blockCount": 28,
"rank": 8,
"emptyBlocks": 0,
"slug": "binancepool",
"avgMatchRate": 99.99,
"avgFeeDelta": "-0.05834286",
"poolUniqueId": 105
},
{
"poolId": 5,
"name": "Luxor",
"link": "https://mining.luxor.tech",
"blockCount": 28,
"rank": 9,
"emptyBlocks": 0,
"slug": "luxor",
"avgMatchRate": 100,
"avgFeeDelta": "-0.05496071",
"poolUniqueId": 4
},
{
"poolId": 143,
"name": "OCEAN",
"link": "https://ocean.xyz/",
"blockCount": 12,
"rank": 10,
"emptyBlocks": 0,
"slug": "ocean",
"avgMatchRate": 91.9,
"avgFeeDelta": "-0.14650833",
"poolUniqueId": 142
},
{
"poolId": 44,
"name": "Braiins Pool",
"link": "https://braiins.com/pool",
"blockCount": 12,
"rank": 11,
"emptyBlocks": 0,
"slug": "braiinspool",
"avgMatchRate": 100,
"avgFeeDelta": "-0.03553333",
"poolUniqueId": 43
},
{
"poolId": 113,
"name": "SBI Crypto",
"link": "https://sbicrypto.com",
"blockCount": 8,
"rank": 12,
"emptyBlocks": 0,
"slug": "sbicrypto",
"avgMatchRate": 98.65,
"avgFeeDelta": "-0.04246250",
"poolUniqueId": 112
},
{
"poolId": 152,
"name": "Carbon Negative",
"link": "https://github.com/bitcoin-data/mining-pools/issues/48",
"blockCount": 7,
"rank": 13,
"emptyBlocks": 0,
"slug": "carbonnegative",
"avgMatchRate": 99.75,
"avgFeeDelta": "-0.04407143",
"poolUniqueId": 151
},
{
"poolId": 7,
"name": "BTC.com",
"link": "https://pool.btc.com",
"blockCount": 5,
"rank": 14,
"emptyBlocks": 0,
"slug": "btccom",
"avgMatchRate": 99.98,
"avgFeeDelta": "-0.02496000",
"poolUniqueId": 6
},
{
"poolId": 162,
"name": "Mining Squared",
"link": "https://pool.bsquared.network/",
"blockCount": 4,
"rank": 15,
"emptyBlocks": 0,
"slug": "miningsquared",
"avgMatchRate": 100,
"avgFeeDelta": "-0.00915000",
"poolUniqueId": 161
},
{
"poolId": 95,
"name": "Poolin",
"link": "https://www.poolin.com",
"blockCount": 4,
"rank": 16,
"emptyBlocks": 0,
"slug": "poolin",
"avgMatchRate": 100,
"avgFeeDelta": "-0.26485000",
"poolUniqueId": 94
},
{
"poolId": 1,
"name": "Unknown",
"link": "https://learnmeabitcoin.com/technical/coinbase-transaction",
"blockCount": 4,
"rank": 17,
"emptyBlocks": 0,
"slug": "unknown",
"avgMatchRate": 100,
"avgFeeDelta": "-0.06490000",
"poolUniqueId": 0
},
{
"poolId": 144,
"name": "WhitePool",
"link": "https://whitebit.com/mining-pool",
"blockCount": 3,
"rank": 18,
"emptyBlocks": 0,
"slug": "whitepool",
"avgMatchRate": 100,
"avgFeeDelta": "-0.01293333",
"poolUniqueId": 143
},
{
"poolId": 3,
"name": "ULTIMUSPOOL",
"link": "https://www.ultimuspool.com",
"blockCount": 1,
"rank": 19,
"emptyBlocks": 0,
"slug": "ultimuspool",
"avgMatchRate": 100,
"avgFeeDelta": "-0.16130000",
"poolUniqueId": 2
},
{
"poolId": 50,
"name": "Solo CK",
"link": "https://solo.ckpool.org",
"blockCount": 1,
"rank": 20,
"emptyBlocks": 0,
"slug": "solock",
"avgMatchRate": 100,
"avgFeeDelta": "-0.01510000",
"poolUniqueId": 49
},
{
"poolId": 158,
"name": "BitFuFuPool",
"link": "https://www.bitfufu.com/pool",
"blockCount": 1,
"rank": 21,
"emptyBlocks": 0,
"slug": "bitfufupool",
"avgMatchRate": 100,
"avgFeeDelta": "-0.01630000",
"poolUniqueId": 157
}
],
"blockCount": 1024,
"lastEstimatedHashrate": 786391245138648900000,
"lastEstimatedHashrate3d": 797683179385121300000,
"lastEstimatedHashrate1w": 827836055441520300000
}

View File

@ -0,0 +1,55 @@
{
"txid": "242f3fff9ca7d5aea7a7a57d886f3fa7329e24fac948598a991b3a3dd631cd29",
"version": 2,
"locktime": 0,
"vin": [
{
"txid": "fb16c141d22a3a7af5e44e7478a8663866c35d554cd85107ec8a99b97e5a72e9",
"vout": 0,
"prevout": {
"scriptpubkey": "76a914099f831c49289c7b9cc07a9a632867ecd51a105a88ac",
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 099f831c49289c7b9cc07a9a632867ecd51a105a OP_EQUALVERIFY OP_CHECKSIG",
"scriptpubkey_type": "p2pkh",
"scriptpubkey_address": "1stAprEZapjCYGUACUcXohQqSHF9MU5Kj",
"value": 50000
},
"scriptsig": "483045022100f44a50e894c30b28400933da120b872ff15bd2e66dd034ffbbb925fd054dd60f02206ba477a9da3fae68a9f420f53bc25493270bd987d13b75fef39cf514aea9cdb3014104ea83ffe426ee6b6827029c72fbdcb0a1602829c1fe384637a7d178aa62a6a1e3d7f29250e001ee708a93ca9771f50ee638aaaaef8941c8a7e2bad5494b23e0df",
"scriptsig_asm": "OP_PUSHBYTES_72 3045022100f44a50e894c30b28400933da120b872ff15bd2e66dd034ffbbb925fd054dd60f02206ba477a9da3fae68a9f420f53bc25493270bd987d13b75fef39cf514aea9cdb301 OP_PUSHBYTES_65 04ea83ffe426ee6b6827029c72fbdcb0a1602829c1fe384637a7d178aa62a6a1e3d7f29250e001ee708a93ca9771f50ee638aaaaef8941c8a7e2bad5494b23e0df",
"is_coinbase": false,
"sequence": 4294967293
}
],
"vout": [
{
"scriptpubkey": "5120a2fc5cb445755389eed295ab9d594b0facd3e00840a3e477fa7c025412c53795",
"scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 a2fc5cb445755389eed295ab9d594b0facd3e00840a3e477fa7c025412c53795",
"scriptpubkey_type": "v1_p2tr",
"scriptpubkey_address": "bc1p5t79edz9w4fcnmkjjk4e6k2tp7kd8cqggz37gal60sp9gyk9x72sk4mk0f",
"value": 49394
}
],
"size": 233,
"weight": 932,
"sigops": 0,
"fee": 606,
"status": {
"confirmed": false
},
"order": 701313494,
"vsize": 233,
"adjustedVsize": 233,
"feePerVsize": 2.6008583690987126,
"adjustedFeePerVsize": 2.6008583690987126,
"effectiveFeePerVsize": 2.6008583690987126,
"firstSeen": 1743541407,
"inputs": [],
"cpfpDirty": false,
"ancestors": [],
"descendants": [],
"bestDescendant": null,
"position": {
"block": 0,
"vsize": 318595.5
},
"flags": 1099511645193
}

View File

@ -0,0 +1,34 @@
{
"replacements": {
"tx": {
"txid": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698",
"fee": 960,
"vsize": 224,
"value": 49040,
"rate": 4.285714285714286,
"time": 1743541726,
"rbf": true,
"fullRbf": false
},
"time": 1743541726,
"fullRbf": false,
"replaces": [
{
"tx": {
"txid": "242f3fff9ca7d5aea7a7a57d886f3fa7329e24fac948598a991b3a3dd631cd29",
"fee": 606,
"vsize": 233,
"value": 49394,
"rate": 2.6008583690987126,
"time": 1743541407,
"rbf": true
},
"time": 1743541407,
"interval": 319,
"fullRbf": false,
"replaces": []
}
]
},
"replaces": null
}

View File

@ -0,0 +1,579 @@
{
"blocks": [
{
"id": "0000000000000000000079f5b74b6533abb0b1ece06570d8d157b5bebd1460b4",
"height": 890440,
"version": 559235072,
"timestamp": 1743535677,
"bits": 386038124,
"nonce": 2920325684,
"difficulty": 113757508810854,
"merkle_root": "c793d5fdbfb1ebe99e14a13a6d65370057d311774d33c71da166663b18722474",
"tx_count": 3823,
"size": 1578209,
"weight": 3993461,
"previousblockhash": "000000000000000000020fb2e24425793e17e60e188205dc1694d221790348b2",
"mediantime": 1743532406,
"stale": false,
"extras": {
"reward": 319838750,
"coinbaseRaw": "0348960d082f5669614254432f2cfabe6d6d294719da11c017243828bf32c405341db7f19387fee92c25413c45e114907f9810000000000000001058bf9601429f9fa7a6c160d10d00000000000000",
"orphans": [],
"medianFee": 4,
"feeRange": [
3,
3,
3.0191082802547773,
3.980952380952381,
5,
10,
427.748502994012
],
"totalFees": 7338750,
"avgFee": 1920,
"avgFeeRate": 7,
"utxoSetChange": 4093,
"avgTxSize": 412.71000000000004,
"totalInputs": 7430,
"totalOutputs": 11523,
"totalOutputAmt": 547553568373,
"segwitTotalTxs": 3432,
"segwitTotalSize": 1467920,
"segwitTotalWeight": 3552413,
"feePercentiles": null,
"virtualSize": 998365.25,
"coinbaseAddress": "1PuJjnF476W3zXfVYmJfGnouzFDAXakkL4",
"coinbaseAddresses": [
"1PuJjnF476W3zXfVYmJfGnouzFDAXakkL4"
],
"coinbaseSignature": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 fb37342f6275b13936799def06f2eb4c0f201515 OP_EQUALVERIFY OP_CHECKSIG",
"coinbaseSignatureAscii": "\u0003H–\r\b/ViaBTC/,ú¾mm)G\u0019Ú\u0011À\u0017$8(¿2Ä\u00054\u001d·ñ“‡þé,%A<Eá\u0014˜\u0010\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0010X¿–\u0001BŸŸ§¦Á`Ñ\r\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
"header": "00405521b248037921d29416dc0582180ee6173e792544e2b20f02000000000000000000742472183b6666a11dc7334d7711d3570037656d3aa1149ee9ebb1bffdd593c73d3eec676c79021734a210ae",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 73,
"name": "ViaBTC",
"slug": "viabtc",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 7342711,
"expectedWeight": 3991920,
"similarity": 0.9978345275528634
}
},
{
"id": "00000000000000000000ef5a27459785ea4c91e05f64adfad306af6dfc0cd19c",
"height": 890441,
"version": 540188672,
"timestamp": 1743535803,
"bits": 386038124,
"nonce": 3418993591,
"difficulty": 113757508810854,
"merkle_root": "25457e2f9e9b55cacde6166acc58ebc2367007a7af5d9b39f46dc1ce060fd63e",
"tx_count": 2813,
"size": 1671284,
"weight": 3993398,
"previousblockhash": "0000000000000000000079f5b74b6533abb0b1ece06570d8d157b5bebd1460b4",
"mediantime": 1743532471,
"stale": false,
"extras": {
"reward": 315271317,
"coinbaseRaw": "0349960d04bb3eec672f466f756e6472792055534120506f6f6c202364726f70676f6c642f01530a4561e7020000000000",
"orphans": [],
"medianFee": 2.0116279069767438,
"feeRange": [
1.0567375886524824,
1.1917098445595855,
1.9007678676904902,
2.1298076923076925,
2.825034262220192,
3.175202156334232,
121
],
"totalFees": 2771317,
"avgFee": 985,
"avgFeeRate": 2,
"utxoSetChange": 37,
"avgTxSize": 593.97,
"totalInputs": 8679,
"totalOutputs": 8716,
"totalOutputAmt": 299780221694,
"segwitTotalTxs": 2379,
"segwitTotalSize": 1535232,
"segwitTotalWeight": 3449298,
"feePercentiles": null,
"virtualSize": 998349.5,
"coinbaseAddress": "bc1pp7w6kxnj7lzgm29pmuhezwl0vjdlcrthqukll5gn9xuqfq5n673smy4m63",
"coinbaseAddresses": [
"bc1pp7w6kxnj7lzgm29pmuhezwl0vjdlcrthqukll5gn9xuqfq5n673smy4m63",
"bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38"
],
"coinbaseSignature": "OP_PUSHNUM_1 OP_PUSHBYTES_32 0f9dab1a72f7c48da8a1df2f913bef649bfc0d77072dffd11329b8048293d7a3",
"coinbaseSignatureAscii": "\u0003I–\r\u0004»>ìg/Foundry USA Pool #dropgold/\u0001S\nEaç\u0002\u0000\u0000\u0000\u0000\u0000",
"header": "00a03220b46014bdbeb557d1d87065e0ecb1b0ab33654bb7f579000000000000000000003ed60f06cec16df4399b5dafa7077036c2eb58cc6a16e6cdca559b9e2f7e4525bb3eec676c790217b7b3c9cb",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 111,
"name": "Foundry USA",
"slug": "foundryusa",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 2792968,
"expectedWeight": 3991959,
"similarity": 0.9951416839808291
}
},
{
"id": "000000000000000000014845978a876b3d8bf5d489b9d87e88873952b06ddfce",
"height": 890442,
"version": 557981696,
"timestamp": 1743536834,
"bits": 386038124,
"nonce": 470697326,
"difficulty": 113757508810854,
"merkle_root": "5e92e681c1db2797a5b3e5016729059f8b60a256cafb51d835dac2b3964c0db4",
"tx_count": 3566,
"size": 1628328,
"weight": 3993552,
"previousblockhash": "00000000000000000000ef5a27459785ea4c91e05f64adfad306af6dfc0cd19c",
"mediantime": 1743532867,
"stale": false,
"extras": {
"reward": 318057766,
"coinbaseRaw": "034a960d194d696e656420627920416e74506f6f6c204d000201e15e2989fabe6d6dd599e9dfa40be51f1517c8f512c5c3d51c7656182f1df335d34b98ee02c527db080000000000000000004f92b702000000000000",
"orphans": [],
"medianFee": 3.00860164711668,
"feeRange": [
1.5174418604651163,
2.0140845070422535,
2.492354740061162,
3,
4.020942408376963,
7,
200
],
"totalFees": 5557766,
"avgFee": 1558,
"avgFeeRate": 5,
"utxoSetChange": 1971,
"avgTxSize": 456.48,
"totalInputs": 7938,
"totalOutputs": 9909,
"totalOutputAmt": 900044492230,
"segwitTotalTxs": 3214,
"segwitTotalSize": 1526463,
"segwitTotalWeight": 3586200,
"feePercentiles": null,
"virtualSize": 998388,
"coinbaseAddress": "37jKPSmbEGwgfacCr2nayn1wTaqMAbA94Z",
"coinbaseAddresses": [
"37jKPSmbEGwgfacCr2nayn1wTaqMAbA94Z",
"39C7fxSzEACPjM78Z7xdPxhf7mKxJwvfMJ"
],
"coinbaseSignature": "OP_HASH160 OP_PUSHBYTES_20 42402a28dd61f2718a4b27ae72a4791d5bbdade7 OP_EQUAL",
"coinbaseSignatureAscii": "\u0003J–\r\u0019Mined by AntPool M\u0000\u0002\u0001á^)‰ú¾mmՙéߤ\u000bå\u001f\u0015\u0017Èõ\u0012ÅÃÕ\u001cvV\u0018/\u001dó5ÓK˜î\u0002Å'Û\b\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000O’·\u0002\u0000\u0000\u0000\u0000\u0000\u0000",
"header": "002042219cd10cfc6daf06d3faad645fe0914cea859745275aef00000000000000000000b40d4c96b3c2da35d851fbca56a2608b9f05296701e5b3a59727dbc181e6925ec242ec676c7902176e450e1c",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 44,
"name": "AntPool",
"slug": "antpool",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 5764747,
"expectedWeight": 3991786,
"similarity": 0.9029319155137951
}
},
{
"id": "000000000000000000026e08b270834273511b353bed30d54706211adc96f5f6",
"height": 890443,
"version": 706666496,
"timestamp": 1743537197,
"bits": 386038124,
"nonce": 321696065,
"difficulty": 113757508810854,
"merkle_root": "3d7574f7eca741fa94b4690868a242e5b286f8a0417ad0275d4ab05893e96350",
"tx_count": 2155,
"size": 1700002,
"weight": 3993715,
"previousblockhash": "000000000000000000014845978a876b3d8bf5d489b9d87e88873952b06ddfce",
"mediantime": 1743533789,
"stale": false,
"extras": {
"reward": 315112344,
"coinbaseRaw": "034b960d21202020204d696e656420627920536563706f6f6c2020202070000b05e388958c01fabe6d6db7ae4bfa7b1294e16e800b4563f1f5ddeb5c0740319eba45600f3f05d2d7272910000000000000000000c2cb7e020000",
"orphans": [],
"medianFee": 1.4360674424569184,
"feeRange": [
1,
1.0135135135135136,
1.09717868338558,
2.142857142857143,
3.009584664536741,
4.831858407079646,
196.07843137254903
],
"totalFees": 2612344,
"avgFee": 1212,
"avgFeeRate": 2,
"utxoSetChange": -2880,
"avgTxSize": 788.64,
"totalInputs": 9773,
"totalOutputs": 6893,
"totalOutputAmt": 264603969671,
"segwitTotalTxs": 1933,
"segwitTotalSize": 1556223,
"segwitTotalWeight": 3418707,
"feePercentiles": null,
"virtualSize": 998428.75,
"coinbaseAddress": "3Eif1JfqeMERRsQHtvGEacNN9hhuvnsfe9",
"coinbaseAddresses": [
"3Eif1JfqeMERRsQHtvGEacNN9hhuvnsfe9",
"3Awm3FNpmwrbvAFVThRUFqgpbVuqWisni9"
],
"coinbaseSignature": "OP_HASH160 OP_PUSHBYTES_20 8ee90177614ecde53314fd67c46162f315852a07 OP_EQUAL",
"coinbaseSignatureAscii": "\u0003K–\r! Mined by Secpool p\u0000\u000b\u0005㈕Œ\u0001ú¾mm·®Kú{\u0012”án€\u000bEcñõÝë\\\u0007@1žºE`\u000f?\u0005Ò×')\u0010\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000ÂË~\u0002\u0000\u0000",
"header": "00e01e2acedf6db0523987887ed8b989d4f58b3d6b878a974548010000000000000000005063e99358b04a5d27d07a41a0f886b2e542a2680869b494fa41a7ecf774753d2d44ec676c79021741b12c13",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 141,
"name": "SECPOOL",
"slug": "secpool",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 2623934,
"expectedWeight": 3991917,
"similarity": 0.9951244468050102
}
},
{
"id": "00000000000000000001b36ec470ae4ec39f5d975f665d6f40e9d35bfa65290d",
"height": 890444,
"version": 671080448,
"timestamp": 1743539347,
"bits": 386038124,
"nonce": 994357124,
"difficulty": 113757508810854,
"merkle_root": "c891d4bf68e22916274b667eb3287d50da2ddd63f8dad892da045cc2ad4a7b21",
"tx_count": 3797,
"size": 1500309,
"weight": 3993525,
"previousblockhash": "000000000000000000026e08b270834273511b353bed30d54706211adc96f5f6",
"mediantime": 1743533986,
"stale": false,
"extras": {
"reward": 318708524,
"coinbaseRaw": "034c960d082f5669614254432f2cfabe6d6d45b7fd7ab53a0914da7dcc9d21fe44f0936f5354169a56df9d5139f07afbc2b41000000000000000106fc0eb03f0ac2e851d18d8d9f85ad70000000000",
"orphans": [],
"medianFee": 4.064775540157046,
"feeRange": [
3.014354066985646,
3.18368700265252,
3.602836879432624,
4.231825525040388,
5.581730769230769,
10,
697.7151162790698
],
"totalFees": 6208524,
"avgFee": 1635,
"avgFeeRate": 6,
"utxoSetChange": 5755,
"avgTxSize": 395.02,
"totalInputs": 6681,
"totalOutputs": 12436,
"totalOutputAmt": 835839828101,
"segwitTotalTxs": 3351,
"segwitTotalSize": 1354446,
"segwitTotalWeight": 3410181,
"feePercentiles": null,
"virtualSize": 998381.25,
"coinbaseAddress": "1PuJjnF476W3zXfVYmJfGnouzFDAXakkL4",
"coinbaseAddresses": [
"1PuJjnF476W3zXfVYmJfGnouzFDAXakkL4"
],
"coinbaseSignature": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 fb37342f6275b13936799def06f2eb4c0f201515 OP_EQUALVERIFY OP_CHECKSIG",
"coinbaseSignatureAscii": "\u0003L–\r\b/ViaBTC/,ú¾mmE·ýzµ:\t\u0014Ú}̝!þDð“oST\u0016šQ9ðzû´\u0010\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0010oÀë\u0003ð¬.…\u001d\u0018ØÙøZ×\u0000\u0000\u0000\u0000\u0000",
"header": "00e0ff27f6f596dc1a210647d530ed3b351b5173428370b2086e02000000000000000000217b4aadc25c04da92d8daf863dd2dda507d28b37e664b271629e268bfd491c8934cec676c79021784af443b",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 73,
"name": "ViaBTC",
"slug": "viabtc",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 6253024,
"expectedWeight": 3991868,
"similarity": 0.9862862477811569
}
},
{
"id": "00000000000000000001402065f940b9475159bdc962c92a66d3c6652ffa338a",
"height": 890445,
"version": 601202688,
"timestamp": 1743539574,
"bits": 386038124,
"nonce": 1647397133,
"difficulty": 113757508810854,
"merkle_root": "61d8294afa8f6bafa4d979a77d187dee5f75a6392f957ea647d96eefbbbc5e9b",
"tx_count": 3579,
"size": 1659862,
"weight": 3993406,
"previousblockhash": "00000000000000000001b36ec470ae4ec39f5d975f665d6f40e9d35bfa65290d",
"mediantime": 1743535677,
"stale": false,
"extras": {
"reward": 315617086,
"coinbaseRaw": "034d960d04764dec672f466f756e6472792055534120506f6f6c202364726f70676f6c642f4fac7c451540000000000000",
"orphans": [],
"medianFee": 2.5565329189526835,
"feeRange": [
1.521613832853026,
2,
2.2411347517730498,
3,
3,
3.954954954954955,
162.78343949044586
],
"totalFees": 3117086,
"avgFee": 871,
"avgFeeRate": 3,
"utxoSetChange": 1881,
"avgTxSize": 463.65000000000003,
"totalInputs": 7893,
"totalOutputs": 9774,
"totalOutputAmt": 324878597485,
"segwitTotalTxs": 3189,
"segwitTotalSize": 1538741,
"segwitTotalWeight": 3509030,
"feePercentiles": null,
"virtualSize": 998351.5,
"coinbaseAddress": "bc1pp7w6kxnj7lzgm29pmuhezwl0vjdlcrthqukll5gn9xuqfq5n673smy4m63",
"coinbaseAddresses": [
"bc1pp7w6kxnj7lzgm29pmuhezwl0vjdlcrthqukll5gn9xuqfq5n673smy4m63",
"bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38"
],
"coinbaseSignature": "OP_PUSHNUM_1 OP_PUSHBYTES_32 0f9dab1a72f7c48da8a1df2f913bef649bfc0d77072dffd11329b8048293d7a3",
"coinbaseSignatureAscii": "\u0003M–\r\u0004vMìg/Foundry USA Pool #dropgold/O¬|E\u0015@\u0000\u0000\u0000\u0000\u0000\u0000",
"header": "00a0d5230d2965fa5bd3e9406f5d665f975d9fc34eae70c46eb3010000000000000000009b5ebcbbef6ed947a67e952f39a6755fee7d187da779d9a4af6b8ffa4a29d861764dec676c7902170d493162",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 111,
"name": "Foundry USA",
"slug": "foundryusa",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 3145370,
"expectedWeight": 3991903,
"similarity": 0.9903353189076812
}
},
{
"id": "00000000000000000001fe3d26b02146d0fc2192db06cbc12478d4b347f5306b",
"height": 890446,
"version": 537722880,
"timestamp": 1743541107,
"bits": 386038124,
"nonce": 826569764,
"difficulty": 113757508810854,
"merkle_root": "d9b320d7cb5aace80ca20b934b13b4a272121fbdd59f3aaba690e0326ca2c144",
"tx_count": 3998,
"size": 1541360,
"weight": 3993545,
"previousblockhash": "00000000000000000001402065f940b9475159bdc962c92a66d3c6652ffa338a",
"mediantime": 1743535803,
"stale": false,
"extras": {
"reward": 317976882,
"coinbaseRaw": "034e960d20202020204d696e656420627920536563706f6f6c2020202070001b04fad5fdfefabe6d6d59dd8ebce6e5aab8fb943bbdcede474b6f2d00a395a717970104a6958c17f1ca100000000000000000008089c9350200",
"orphans": [],
"medianFee": 3.3750830641948864,
"feeRange": [
2.397163120567376,
3,
3,
3.463647199046484,
4.49438202247191,
7.213930348258707,
476.1904761904762
],
"totalFees": 5476882,
"avgFee": 1370,
"avgFeeRate": 5,
"utxoSetChange": 4951,
"avgTxSize": 385.41,
"totalInputs": 7054,
"totalOutputs": 12005,
"totalOutputAmt": 983289729453,
"segwitTotalTxs": 3538,
"segwitTotalSize": 1396505,
"segwitTotalWeight": 3414233,
"feePercentiles": null,
"virtualSize": 998386.25,
"coinbaseAddress": "3Eif1JfqeMERRsQHtvGEacNN9hhuvnsfe9",
"coinbaseAddresses": [
"3Eif1JfqeMERRsQHtvGEacNN9hhuvnsfe9",
"3Awm3FNpmwrbvAFVThRUFqgpbVuqWisni9"
],
"coinbaseSignature": "OP_HASH160 OP_PUSHBYTES_20 8ee90177614ecde53314fd67c46162f315852a07 OP_EQUAL",
"coinbaseSignatureAscii": "\u0003N–\r Mined by Secpool p\u0000\u001b\u0004úÕýþú¾mmYݎ¼æåª¸û”;½ÎÞGKo-\u0000£•§\u0017—\u0001\u0004¦•Œ\u0017ñÊ\u0010\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000€‰É5\u0002\u0000",
"header": "00000d208a33fa2f65c6d3662ac962c9bd595147b940f96520400100000000000000000044c1a26c32e090a6ab3a9fd5bd1f1272a2b4134b930ba20ce8ac5acbd720b3d97353ec676c79021724744431",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 141,
"name": "SECPOOL",
"slug": "secpool",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 5601814,
"expectedWeight": 3991928,
"similarity": 0.9537877497871488
}
},
{
"id": "0000000000000000000004cc6260ec7065ee8f65591864fb4c720ec110e3e2ab",
"height": 890447,
"version": 568860672,
"timestamp": 1743541240,
"bits": 386038124,
"nonce": 4008077709,
"difficulty": 113757508810854,
"merkle_root": "8c3b098e4e50b67075a4fc52bf4cd603aaa450c240c18a865c9ddc0f27104f5f",
"tx_count": 1919,
"size": 1747789,
"weight": 3993172,
"previousblockhash": "00000000000000000001fe3d26b02146d0fc2192db06cbc12478d4b347f5306b",
"mediantime": 1743536834,
"stale": false,
"extras": {
"reward": 314435106,
"coinbaseRaw": "034f960d0f2f736c7573682f65000002fba05ef1fabe6d6df8d29032ea6f9ab1debd223651f30887df779c6195869e70a7b787b3a15f4b1710000000000000000000ee5f0c00b20200000000",
"orphans": [],
"medianFee": 1.4653828213500366,
"feeRange": [
1.0845070422535212,
1.2,
1.51,
2.0141129032258065,
2.3893805309734515,
4.025477707006369,
300.0065359477124
],
"totalFees": 1935106,
"avgFee": 1008,
"avgFeeRate": 1,
"utxoSetChange": -4244,
"avgTxSize": 910.58,
"totalInputs": 9909,
"totalOutputs": 5665,
"totalOutputAmt": 210763861504,
"segwitTotalTxs": 1720,
"segwitTotalSize": 1629450,
"segwitTotalWeight": 3519924,
"feePercentiles": null,
"virtualSize": 998293,
"coinbaseAddress": "34XC8GbijKCCvppNvhw4Ra8QZdWsg8tC11",
"coinbaseAddresses": [
"34XC8GbijKCCvppNvhw4Ra8QZdWsg8tC11"
],
"coinbaseSignature": "OP_HASH160 OP_PUSHBYTES_20 1f0cbbec8bc4c945e4e16249b11eee911eded55f OP_EQUAL",
"coinbaseSignatureAscii": "\u0003O–\r\u000f/slush/e\u0000\u0000\u0002û ^ñú¾mmøÒ2êoš±Þ½\"6Qó\b‡ßwœa•†žp§·‡³¡_K\u0017\u0010\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000î_\f\u0000²\u0002\u0000\u0000\u0000\u0000",
"header": "0020e8216b30f547b3d47824c1cb06db9221fcd04621b0263dfe010000000000000000005f4f10270fdc9d5c868ac140c250a4aa03d64cbf52fca47570b6504e8e093b8cf853ec676c7902178d69e6ee",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 43,
"name": "Braiins Pool",
"slug": "braiinspool",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 2059571,
"expectedWeight": 3991720,
"similarity": 0.9149852183486826
}
}
],
"mempool-blocks": [
{
"blockSize": 1779311,
"blockVSize": 997968.5,
"nTx": 2132,
"totalFees": 2902870,
"medianFee": 2.0479263387949875,
"feeRange": [
1.0721153846153846,
1.9980563654033041,
2.2195704057279237,
3.009493670886076,
3.4955223880597015,
6.0246913580246915,
218.1818181818182
]
},
{
"blockSize": 1959636,
"blockVSize": 997903.5,
"nTx": 497,
"totalFees": 1093076,
"medianFee": 1.102049424602265,
"feeRange": [
1.0401794819498267,
1.0548148148148149,
1.0548148148148149,
1.0548148148148149,
1.0548148148148149,
1.0761096766260911,
1.1021605957228275
]
},
{
"blockSize": 1477260,
"blockVSize": 997997.25,
"nTx": 720,
"totalFees": 1016195,
"medianFee": 1.007409072434199,
"feeRange": [
1,
1.0019120458891013,
1.0040863981319323,
1.0081019768823594,
1.018450184501845,
1.0203327171903882,
1.0485018498190837
]
},
{
"blockSize": 1021308,
"blockVSize": 431071.5,
"nTx": 823,
"totalFees": 432342,
"medianFee": 0,
"feeRange": [
1,
1,
1,
1.0028011204481793,
1.0042075736325387,
1.0053475935828877,
1.0068649885583525
]
}
]
}

View File

@ -0,0 +1,68 @@
{
"mempool-blocks": [
{
"blockSize": 1780038,
"blockVSize": 997989.75,
"nTx": 2134,
"totalFees": 2919589,
"medianFee": 2.0479263387949875,
"feeRange": [
1.0101010101010102,
2,
2.235576923076923,
3.010452961672474,
3.5240274599542336,
6.032085561497326,
218.1818181818182
]
},
{
"blockSize": 1958446,
"blockVSize": 997996,
"nTx": 503,
"totalFees": 1093277,
"medianFee": 1.102049424602265,
"feeRange": [
1.0101010101010102,
1.0548148148148149,
1.0548148148148149,
1.0548148148148149,
1.067677314564158,
1.0761096766260911,
1.1021605957228275
]
},
{
"blockSize": 1477611,
"blockVSize": 997927.5,
"nTx": 725,
"totalFees": 1016311,
"medianFee": 1.0075971559364956,
"feeRange": [
1,
1.0019334049409236,
1.0042075736325387,
1.0081019768823594,
1.018450184501845,
1.0203327171903882,
1.0548148148148149
]
},
{
"blockSize": 1028219,
"blockVSize": 435137,
"nTx": 833,
"totalFees": 436414,
"medianFee": 0,
"feeRange": [
1,
1,
1,
1.0028011204481793,
1.004231311706629,
1.0053475935828877,
1.0068649885583525
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
{
"txReplaced": {
"txid": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698"
}
}

View File

@ -0,0 +1,9 @@
{
"ancestors": [],
"bestDescendant": null,
"descendants": [],
"effectiveFeePerVsize": 4.285714285714286,
"sigops": 4,
"fee": 960,
"adjustedVsize": 224
}

View File

@ -0,0 +1,36 @@
{
"replacements": {
"tx": {
"txid": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698",
"fee": 960,
"vsize": 224,
"value": 49040,
"rate": 4.285714285714286,
"time": 1743541726,
"rbf": true,
"fullRbf": false
},
"time": 1743541726,
"fullRbf": false,
"replaces": [
{
"tx": {
"txid": "242f3fff9ca7d5aea7a7a57d886f3fa7329e24fac948598a991b3a3dd631cd29",
"fee": 606,
"vsize": 233,
"value": 49394,
"rate": 2.6008583690987126,
"time": 1743541407,
"rbf": true
},
"time": 1743541407,
"interval": 319,
"fullRbf": false,
"replaces": []
}
]
},
"replaces": [
"242f3fff9ca7d5aea7a7a57d886f3fa7329e24fac948598a991b3a3dd631cd29"
]
}

View File

@ -0,0 +1,38 @@
{
"txid": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698",
"version": 2,
"locktime": 0,
"vin": [
{
"txid": "fb16c141d22a3a7af5e44e7478a8663866c35d554cd85107ec8a99b97e5a72e9",
"vout": 0,
"prevout": {
"scriptpubkey": "76a914099f831c49289c7b9cc07a9a632867ecd51a105a88ac",
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 099f831c49289c7b9cc07a9a632867ecd51a105a OP_EQUALVERIFY OP_CHECKSIG",
"scriptpubkey_type": "p2pkh",
"scriptpubkey_address": "1stAprEZapjCYGUACUcXohQqSHF9MU5Kj",
"value": 50000
},
"scriptsig": "483045022100ab59cf33b33eab1f76286a0fa6962c12171f2133931fec3616f96883551e6c9d02205cacbae788359f2a32ff629e732be0d95843440a3d1c222a63095f83fb69f438014104ea83ffe426ee6b6827029c72fbdcb0a1602829c1fe384637a7d178aa62a6a1e3d7f29250e001ee708a93ca9771f50ee638aaaaef8941c8a7e2bad5494b23e0df",
"scriptsig_asm": "OP_PUSHBYTES_72 3045022100ab59cf33b33eab1f76286a0fa6962c12171f2133931fec3616f96883551e6c9d02205cacbae788359f2a32ff629e732be0d95843440a3d1c222a63095f83fb69f43801 OP_PUSHBYTES_65 04ea83ffe426ee6b6827029c72fbdcb0a1602829c1fe384637a7d178aa62a6a1e3d7f29250e001ee708a93ca9771f50ee638aaaaef8941c8a7e2bad5494b23e0df",
"is_coinbase": false,
"sequence": 4294967293
}
],
"vout": [
{
"scriptpubkey": "76a914099f831c49289c7b9cc07a9a632867ecd51a105a88ac",
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 099f831c49289c7b9cc07a9a632867ecd51a105a OP_EQUALVERIFY OP_CHECKSIG",
"scriptpubkey_type": "p2pkh",
"scriptpubkey_address": "1stAprEZapjCYGUACUcXohQqSHF9MU5Kj",
"value": 49040
}
],
"size": 224,
"weight": 896,
"sigops": 4,
"fee": 960,
"status": {
"confirmed": false
}
}

View File

@ -0,0 +1,3 @@
[
1743541726
]

View File

@ -0,0 +1,116 @@
{
"block": {
"id": "000000000000000000019bfe551da67e0d93dda4b355d16f1fdb4031ba7e5d61",
"height": 890448,
"version": 626941952,
"timestamp": 1743541850,
"bits": 386038124,
"nonce": 1177284424,
"difficulty": 113757508810854,
"merkle_root": "563d862ef4ec95ea97735ca5561e347ca6e216cb56bd451e8bedb1014078d6c3",
"tx_count": 2229,
"size": 1763153,
"weight": 3993275,
"previousblockhash": "0000000000000000000004cc6260ec7065ee8f65591864fb4c720ec110e3e2ab",
"mediantime": 1743537197,
"stale": false,
"extras": {
"reward": 315498786,
"coinbaseRaw": "0350960d0f2f736c7573682fe500c702ff43d142fabe6d6def42d9effd800b9839c832dcfdc6899e137547f15c8a598dbe3a1363f999a0a310000000000000000000e9a2050064dc00000000",
"orphans": [],
"medianFee": 2.144206217858874,
"feeRange": [
1.0845921450151057,
2,
2.2448979591836733,
3,
3.5985915492957745,
6,
217.0212765957447
],
"totalFees": 2998786,
"avgFee": 1345,
"avgFeeRate": 3,
"utxoSetChange": -3073,
"avgTxSize": 790.84,
"totalInputs": 9558,
"totalOutputs": 6485,
"totalOutputAmt": 442206797883,
"segwitTotalTxs": 1986,
"segwitTotalSize": 1676431,
"segwitTotalWeight": 3646495,
"feePercentiles": null,
"virtualSize": 998318.75,
"coinbaseAddress": "34XC8GbijKCCvppNvhw4Ra8QZdWsg8tC11",
"coinbaseAddresses": [
"34XC8GbijKCCvppNvhw4Ra8QZdWsg8tC11"
],
"coinbaseSignature": "OP_HASH160 OP_PUSHBYTES_20 1f0cbbec8bc4c945e4e16249b11eee911eded55f OP_EQUAL",
"coinbaseSignatureAscii": "\u0003P–\r\u000f/slush/å\u0000Ç\u0002ÿCÑBú¾mmïBÙïý€\u000b˜9È2ÜýƉž\u0013uGñ\\ŠY¾:\u0013cù™ £\u0010\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000é¢\u0005\u0000dÜ\u0000\u0000\u0000\u0000",
"header": "00605e25abe2e310c10e724cfb641859658fee6570ec6062cc0400000000000000000000c3d6784001b1ed8b1e45bd56cb16e2a67c341e56a55c7397ea95ecf42e863d565a56ec676c79021748ef2b46",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 43,
"name": "Braiins Pool",
"slug": "braiinspool",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 3287140,
"expectedWeight": 3991809,
"similarity": 0.9079894021278392
}
},
"mempool-blocks": [
{
"blockSize": 1979907,
"blockVSize": 997974.75,
"nTx": 461,
"totalFees": 1429178,
"medianFee": 1.1020797417745662,
"feeRange": [
1.0666666666666667,
1.0746847720659554,
1.102059530141031,
3,
3.4233409610983982,
5.017605633802817,
148.4084084084084
]
},
{
"blockSize": 1363691,
"blockVSize": 997986.5,
"nTx": 1080,
"totalFees": 1023741,
"medianFee": 1.014827018121911,
"feeRange": [
1,
1.0036011703803736,
1.0054683365672958,
1.0186757215619695,
1.0548148148148149,
1.0548148148148149,
1.068146618482189
]
},
{
"blockSize": 1337253,
"blockVSize": 563516.25,
"nTx": 901,
"totalFees": 564834,
"medianFee": 1.0028011204481793,
"feeRange": [
1,
1,
1,
1.0025062656641603,
1.004231311706629,
1.0053475935828877,
1.0068649885583525
]
}
],
"txConfirmed": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698"
}

View File

@ -0,0 +1,116 @@
{
"block": {
"id": "000000000000000000019bfe551da67e0d93dda4b355d16f1fdb4031ba7e5d61",
"height": 890448,
"version": 626941952,
"timestamp": 1743541850,
"bits": 386038124,
"nonce": 1177284424,
"difficulty": 113757508810854,
"merkle_root": "563d862ef4ec95ea97735ca5561e347ca6e216cb56bd451e8bedb1014078d6c3",
"tx_count": 2229,
"size": 1763153,
"weight": 3993275,
"previousblockhash": "0000000000000000000004cc6260ec7065ee8f65591864fb4c720ec110e3e2ab",
"mediantime": 1743537197,
"stale": false,
"extras": {
"reward": 315498786,
"coinbaseRaw": "0350960d0f2f736c7573682fe500c702ff43d142fabe6d6def42d9effd800b9839c832dcfdc6899e137547f15c8a598dbe3a1363f999a0a310000000000000000000e9a2050064dc00000000",
"orphans": [],
"medianFee": 2.144206217858874,
"feeRange": [
1.0845921450151057,
2,
2.2448979591836733,
3,
3.5985915492957745,
6,
217.0212765957447
],
"totalFees": 2998786,
"avgFee": 1345,
"avgFeeRate": 3,
"utxoSetChange": -3073,
"avgTxSize": 790.84,
"totalInputs": 9558,
"totalOutputs": 6485,
"totalOutputAmt": 442206797883,
"segwitTotalTxs": 1986,
"segwitTotalSize": 1676431,
"segwitTotalWeight": 3646495,
"feePercentiles": null,
"virtualSize": 998318.75,
"coinbaseAddress": "34XC8GbijKCCvppNvhw4Ra8QZdWsg8tC11",
"coinbaseAddresses": [
"34XC8GbijKCCvppNvhw4Ra8QZdWsg8tC11"
],
"coinbaseSignature": "OP_HASH160 OP_PUSHBYTES_20 1f0cbbec8bc4c945e4e16249b11eee911eded55f OP_EQUAL",
"coinbaseSignatureAscii": "\u0003P–\r\u000f/slush/å\u0000Ç\u0002ÿCÑBú¾mmïBÙïý€\u000b˜9È2ÜýƉž\u0013uGñ\\ŠY¾:\u0013cù™ £\u0010\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000é¢\u0005\u0000dÜ\u0000\u0000\u0000\u0000",
"header": "00605e25abe2e310c10e724cfb641859658fee6570ec6062cc0400000000000000000000c3d6784001b1ed8b1e45bd56cb16e2a67c341e56a55c7397ea95ecf42e863d565a56ec676c79021748ef2b46",
"utxoSetSize": null,
"totalInputAmt": null,
"pool": {
"id": 43,
"name": "Braiins Pool",
"slug": "braiinspool",
"minerNames": null
},
"matchRate": 100,
"expectedFees": 3287140,
"expectedWeight": 3991809,
"similarity": 0.9079894021278392
}
},
"mempool-blocks": [
{
"blockSize": 1979907,
"blockVSize": 997974.75,
"nTx": 461,
"totalFees": 1429178,
"medianFee": 1.1020797417745662,
"feeRange": [
1.0666666666666667,
1.0746847720659554,
1.102059530141031,
3,
3.4233409610983982,
5.017605633802817,
148.4084084084084
]
},
{
"blockSize": 1363691,
"blockVSize": 997986.5,
"nTx": 1080,
"totalFees": 1023741,
"medianFee": 1.014827018121911,
"feeRange": [
1,
1.0036011703803736,
1.0054683365672958,
1.0186757215619695,
1.0548148148148149,
1.0548148148148149,
1.068146618482189
]
},
{
"blockSize": 1337253,
"blockVSize": 563516.25,
"nTx": 901,
"totalFees": 564834,
"medianFee": 1.0028011204481793,
"feeRange": [
1,
1,
1,
1.0025062656641603,
1.004231311706629,
1.0053475935828877,
1.0068649885583525
]
}
],
"txConfirmed": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698"
}

View File

@ -0,0 +1,75 @@
{
"mempool-blocks": [
{
"blockSize": 1779823,
"blockVSize": 997995.25,
"nTx": 2133,
"totalFees": 2922926,
"medianFee": 2.0479263387949875,
"feeRange": [
1.0825892857142858,
2,
2.2439024390243905,
3.010452961672474,
3.554973821989529,
6.032085561497326,
218.1818181818182
]
},
{
"blockSize": 1957833,
"blockVSize": 997953,
"nTx": 500,
"totalFees": 1093270,
"medianFee": 1.102049424602265,
"feeRange": [
1.0548148148148149,
1.0548148148148149,
1.0548148148148149,
1.0548148148148149,
1.067677314564158,
1.0766488413547237,
1.1021605957228275
]
},
{
"blockSize": 1477864,
"blockVSize": 997999,
"nTx": 730,
"totalFees": 1016458,
"medianFee": 1.0075971559364956,
"feeRange": [
1,
1.0019552465783186,
1.004255319148936,
1.0081019768823594,
1.018450184501845,
1.0203327171903882,
1.0548148148148149
]
},
{
"blockSize": 1030954,
"blockVSize": 436613.5,
"nTx": 838,
"totalFees": 437891,
"medianFee": 0,
"feeRange": [
1,
1,
1,
1.0026525198938991,
1.004231311706629,
1.0053475935828877,
1.0068649885583525
]
}
],
"txPosition": {
"txid": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698",
"position": {
"block": 0,
"vsize": 111102
}
}
}

View File

@ -0,0 +1,75 @@
{
"mempool-blocks": [
{
"blockSize": 1719945,
"blockVSize": 997952.25,
"nTx": 2558,
"totalFees": 3287140,
"medianFee": 2.4046448299072485,
"feeRange": [
1.073446327683616,
2,
2.2567567567567566,
3.0106761565836297,
3.6169014084507043,
6.015037593984962,
218.1818181818182
]
},
{
"blockSize": 2022898,
"blockVSize": 997983.25,
"nTx": 131,
"totalFees": 1098129,
"medianFee": 1.1020797417745662,
"feeRange": [
1.0625,
1.0691217722793642,
1.073436083408885,
1.0761096766260911,
1.080091533180778,
1.102110739151618,
1.1021909190121146
]
},
{
"blockSize": 1363844,
"blockVSize": 997998.5,
"nTx": 1073,
"totalFees": 1023651,
"medianFee": 1.014827018121911,
"feeRange": [
1,
1.003584229390681,
1.0054683365672958,
1.0186757215619695,
1.0548148148148149,
1.0548148148148149,
1.068146618482189
]
},
{
"blockSize": 1335390,
"blockVSize": 562453.5,
"nTx": 902,
"totalFees": 563772,
"medianFee": 1.0028011204481793,
"feeRange": [
1,
1,
1,
1.0025402201524132,
1.004231311706629,
1.0053475935828877,
1.0068649885583525
]
}
],
"txPosition": {
"txid": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698",
"position": {
"block": 0,
"vsize": 128920
}
}
}

View File

@ -0,0 +1,9 @@
{
"txPosition": {
"txid": "b6a53d8a8025c0c5b194345925a99741b645cae0b1f180f0613273029f2bf698",
"position": {
"block": 0,
"vsize": 110880
}
}
}

View File

@ -0,0 +1,37 @@
{
"rbfLatest": [
{
"tx": {
"txid": "f4bae4f626036250fd00d68490e572f65f66417452003a0f4c4d76f17a9fde68",
"fee": 1185,
"vsize": 223,
"value": 41729,
"rate": 5.313901345291479,
"time": 1743587177,
"rbf": true,
"fullRbf": false,
"mined": true
},
"time": 1743587177,
"fullRbf": true,
"replaces": [
{
"tx": {
"txid": "12945412dfc455e0ed6049dc2ee8737756c8d9e2d9a2eb26f366cd5019a0369f",
"fee": 504,
"vsize": 222,
"value": 42410,
"rate": 2.27027027027027,
"time": 1743586081,
"rbf": true
},
"time": 1743586081,
"interval": 1096,
"fullRbf": false,
"replaces": []
}
],
"mined": true
}
]
}

View File

@ -0,0 +1,68 @@
{
"rbfLatest": [
{
"tx": {
"txid": "d313b479acfbae719afb488a078e0fe0e052a67b9f65f73f7c75d3d95fd36acc",
"fee": 672,
"vsize": 167.25,
"value": 29996328,
"rate": 4.017937219730942,
"time": 1743587365,
"rbf": true,
"fullRbf": false
},
"time": 1743587365,
"fullRbf": false,
"replaces": [
{
"tx": {
"txid": "eb5aa786cabda307cc9642cfb9c41a3b405ac20a391eefbe54be7930bea61865",
"fee": 336,
"vsize": 167.5,
"value": 29996664,
"rate": 2.005970149253731,
"time": 1743586424,
"rbf": true
},
"time": 1743586424,
"interval": 941,
"fullRbf": false,
"replaces": []
}
]
},
{
"tx": {
"txid": "f4bae4f626036250fd00d68490e572f65f66417452003a0f4c4d76f17a9fde68",
"fee": 1185,
"vsize": 223,
"value": 41729,
"rate": 5.313901345291479,
"time": 1743587177,
"rbf": true,
"fullRbf": false,
"mined": true
},
"time": 1743587177,
"fullRbf": true,
"replaces": [
{
"tx": {
"txid": "12945412dfc455e0ed6049dc2ee8737756c8d9e2d9a2eb26f366cd5019a0369f",
"fee": 504,
"vsize": 222,
"value": 42410,
"rate": 2.27027027027027,
"time": 1743586081,
"rbf": true
},
"time": 1743586081,
"interval": 1096,
"fullRbf": false,
"replaces": []
}
],
"mined": true
}
]
}

View File

@ -44,6 +44,7 @@
import { PageIdleDetector } from './PageIdleDetector';
import { mockWebSocket } from './websocket';
import { mockWebSocketV2 } from './websocket';
/* global Cypress */
const codes = {
@ -72,6 +73,10 @@ Cypress.Commands.add('mockMempoolSocket', () => {
mockWebSocket();
});
Cypress.Commands.add('mockMempoolSocketV2', () => {
mockWebSocketV2();
});
Cypress.Commands.add('changeNetwork', (network: "testnet" | "testnet4" | "signet" | "liquid" | "mainnet") => {
cy.get('.dropdown-toggle').click().then(() => {
cy.get(`a.${network}`).click().then(() => {

View File

@ -5,6 +5,7 @@ declare namespace Cypress {
waitForSkeletonGone(): Chainable<any>
waitForPageIdle(): Chainable<any>
mockMempoolSocket(): Chainable<any>
mockMempoolSocketV2(): Chainable<any>
changeNetwork(network: "testnet"|"testnet4"|"signet"|"liquid"|"mainnet"): Chainable<any>
}
}

View File

@ -27,6 +27,37 @@ const createMock = (url: string) => {
return mocks[url];
};
export const mockWebSocketV2 = () => {
cy.on('window:before:load', (win) => {
const winWebSocket = win.WebSocket;
cy.stub(win, 'WebSocket').callsFake((url) => {
console.log(url);
if ((new URL(url).pathname.indexOf('/sockjs-node/') !== 0)) {
const { server, websocket } = createMock(url);
win.mockServer = server;
win.mockServer.on('connection', (socket) => {
win.mockSocket = socket;
});
win.mockServer.on('message', (message) => {
console.log(message);
});
return websocket;
} else {
return new winWebSocket(url);
}
});
});
cy.on('window:before:unload', () => {
for (const url in mocks) {
cleanupMock(url);
}
});
};
export const mockWebSocket = () => {
cy.on('window:before:load', (win) => {
const winWebSocket = win.WebSocket;
@ -65,6 +96,27 @@ export const mockWebSocket = () => {
});
};
export const receiveWebSocketMessageFromServer = ({
params
}: { params?: any } = {}) => {
cy.window().then((win) => {
if (params.message) {
console.log('sending message');
win.mockSocket.send(params.message.contents);
}
if (params.file) {
cy.readFile(`cypress/fixtures/${params.file.path}`, 'utf-8').then((fixture) => {
console.log('sending payload');
win.mockSocket.send(JSON.stringify(fixture));
});
}
});
return;
};
export const emitMempoolInfo = ({
params
}: { params?: any } = {}) => {
@ -82,16 +134,22 @@ export const emitMempoolInfo = ({
switch (params.command) {
case "init": {
win.mockSocket.send('{"conversions":{"USD":32365.338815782445}}');
cy.readFile('cypress/fixtures/mainnet_live2hchart.json', 'ascii').then((fixture) => {
cy.readFile('cypress/fixtures/mainnet_live2hchart.json', 'utf-8').then((fixture) => {
win.mockSocket.send(JSON.stringify(fixture));
});
cy.readFile('cypress/fixtures/mainnet_mempoolInfo.json', 'ascii').then((fixture) => {
cy.readFile('cypress/fixtures/mainnet_mempoolInfo.json', 'utf-8').then((fixture) => {
win.mockSocket.send(JSON.stringify(fixture));
});
break;
}
case "rbfTransaction": {
cy.readFile('cypress/fixtures/mainnet_rbf.json', 'ascii').then((fixture) => {
cy.readFile('cypress/fixtures/mainnet_rbf.json', 'utf-8').then((fixture) => {
win.mockSocket.send(JSON.stringify(fixture));
});
break;
}
case 'trackTx': {
cy.readFile('cypress/fixtures/track_tx.json', 'utf-8').then((fixture) => {
win.mockSocket.send(JSON.stringify(fixture));
});
break;

View File

@ -1,12 +1,12 @@
{
"name": "mempool-frontend",
"version": "3.1.0-dev",
"version": "3.2.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "mempool-frontend",
"version": "3.1.0-dev",
"version": "3.2.0",
"license": "GNU Affero General Public License v3.0",
"dependencies": {
"@angular-devkit/build-angular": "^17.3.1",

View File

@ -1,6 +1,6 @@
{
"name": "mempool-frontend",
"version": "3.1.0-dev",
"version": "3.2.0",
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
"license": "GNU Affero General Public License v3.0",
"homepage": "https://mempool.space",
@ -25,14 +25,12 @@
"i18n-extract-from-source": "npm run ng -- extract-i18n --out-file ./src/locale/messages.xlf",
"i18n-pull-from-transifex": "tx pull -a --parallel --minimum-perc 1 --force",
"serve": "npm run generate-config && npm run ng -- serve -c local",
"serve:stg": "npm run generate-config && npm run ng -- serve -c staging",
"serve:local-prod": "npm run generate-config && npm run ng -- serve -c local-prod",
"serve:local-staging": "npm run generate-config && npm run ng -- serve -c local-staging",
"serve:parameterized": "npm run generate-config && npm run ng -- serve -c parameterized",
"start": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local",
"start:parameterized": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c parameterized",
"start:local-esplora": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-esplora",
"start:stg": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c staging",
"start:local-prod": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-prod",
"start:local-staging": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-staging",
"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",
"sync-assets": "rsync -av ./src/resources ./dist/mempool/browser && node sync-assets.js 'dist/mempool/browser/resources/'",
@ -58,8 +56,8 @@
"cypress:run:record": "cypress run --record",
"cypress:open:ci": "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:local-prod 4200 cypress:open",
"cypress:run:ci": "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:local-prod 4200 cypress:run:record",
"cypress:open:ci:staging": "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:local-staging 4200 cypress:open",
"cypress:run:ci:staging": "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:local-staging 4200 cypress:run:record"
"cypress:open: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:open",
"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",

View File

@ -0,0 +1,36 @@
const fs = require('fs');
const PROXY_CONFIG = require('./proxy.conf');
const addApiKeyHeader = (proxyReq, req, res) => {
if (process.env.MEMPOOL_CI_API_KEY) {
proxyReq.setHeader('X-Mempool-Auth', process.env.MEMPOOL_CI_API_KEY);
}
};
PROXY_CONFIG.forEach((entry) => {
const mempoolHostname = process.env.MEMPOOL_HOSTNAME
? process.env.MEMPOOL_HOSTNAME
: 'mempool.space';
const liquidHostname = process.env.LIQUID_HOSTNAME
? process.env.LIQUID_HOSTNAME
: 'liquid.network';
entry.target = entry.target.replace('mempool.space', mempoolHostname);
entry.target = entry.target.replace('liquid.network', liquidHostname);
if (entry.onProxyReq) {
const originalProxyReq = entry.onProxyReq;
entry.onProxyReq = (proxyReq, req, res) => {
originalProxyReq(proxyReq, req, res);
if (process.env.MEMPOOL_CI_API_KEY) {
proxyReq.setHeader('X-Mempool-Auth', process.env.MEMPOOL_CI_API_KEY);
}
};
} else {
entry.onProxyReq = addApiKeyHeader;
}
});
module.exports = PROXY_CONFIG;

View File

@ -1,12 +0,0 @@
const fs = require('fs');
let PROXY_CONFIG = require('./proxy.conf');
PROXY_CONFIG.forEach(entry => {
const hostname = process.env.CYPRESS_REROUTE_TESTNET === 'true' ? 'mempool-staging.fra.mempool.space' : 'node201.fmt.mempool.space';
console.log(`e2e tests running against ${hostname}`);
entry.target = entry.target.replace("mempool.space", hostname);
entry.target = entry.target.replace("liquid.network", "liquid-staging.fmt.mempool.space");
});
module.exports = PROXY_CONFIG;

View File

@ -11,6 +11,7 @@
<div class="about-text">
<h5><ng-container i18n="about.about-the-project">The Mempool Open Source Project</ng-container><ng-template [ngIf]="locale.substr(0, 2) === 'en'"> &reg;</ng-template></h5>
<p i18n>Our mempool and blockchain explorer for the Bitcoin community, focusing on the transaction fee market and multi-layer ecosystem, completely self-hosted without any trusted third-parties.</p>
<h5>Be your own explorer&trade;</h5>
</div>
<video #promoVideo (click)="unmutePromoVideo()" (touchstart)="unmutePromoVideo()" src="/resources/promo-video/mempool-promo.mp4" poster="/resources/promo-video/mempool-promo.jpg" controls loop playsinline [autoplay]="true" [muted]="true">
@ -145,6 +146,15 @@
</svg>
<span>Bull Bitcoin</span>
</a>
<a href="https://fortris.com/" target="_blank" title="Fortris">
<svg id="fortris-logo" viewBox="0 0 140.08 129.13" version="1.1" width="74px" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<defs><style>.fortris-cls-1{fill:#29384a;}.fortris-cls-1,.fortris-cls-2{stroke-width:0px;}.fortris-cls-2{fill:#ff6e72;}</style></defs>
<rect class="fortris-cls-2" x="0" y="0" width="140.08" height="30.59" rx="15.29" ry="15.29" />
<rect class="fortris-cls-2" x="0" y="98.540001" width="69.779999" height="30.59" rx="15.29" ry="15.29" />
<rect class="fortris-cls-2" x="0" y="49.27" width="109.5" height="30.59" rx="15.29" ry="15.29" />
</svg>
<span>Fortris</span>
</a>
<a href="https://exodus.com/" target="_blank" title="Exodus">
<svg width="80" height="80" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg" class="image">
<circle cx="250" cy="250" r="250" fill="#1F2033"/>
@ -449,7 +459,7 @@
Trademark Notice<br>
</div>
<p>
The Mempool Open Source Project&reg;, Mempool Accelerator&trade;, Mempool Enterprise&reg;, Mempool Liquidity&trade;, mempool.space&reg;, Be your own explorer&trade;, Explore the full Bitcoin ecosystem&reg;, Mempool Goggles&trade;, 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&reg;, Mempool Accelerator&reg;, Mempool Enterprise&reg;, Mempool Liquidity&trade;, mempool.space&reg;, Be your own explorer&trade;, Explore the full Bitcoin ecosystem&reg;, Mempool Goggles&trade;, 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.
</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 &lt;https://mempool.space/trademark-policy&gt;.

View File

@ -264,6 +264,6 @@
display: flex;
flex-wrap: wrap;
justify-content: center;
max-width: 800px;
max-width: 850px;
}
}

View File

@ -158,7 +158,7 @@
<!-- MEMPOOL BASE FEE -->
<tr>
<td class="item" i18n="accelerator.mempool-accelerator-fees">Mempool Accelerator fees</td>
<td class="item" i18n="accelerator.mempool-accelerator-fees">Mempool Accelerator® fees</td>
</tr>
<tr class="info" [class.group-last]="!estimate.vsizeFee" [class.dashed-bottom]="!estimate.vsizeFee">
<td class="info">
@ -567,14 +567,29 @@
} @else if (step === 'success') {
<div class="row mb-1 text-center">
<div class="col-sm">
<h1 style="font-size: larger;"><ng-content select="[slot='accelerated-title']"></ng-content><span class="default-slot" i18n="accelerator.success-message">Your transaction is being accelerated!</span></h1>
<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>
}
</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">
<span i18n="accelerator.confirmed-acceleration-with-miners">Your transaction has been accepted for acceleration by our mining pool partners.</span>
@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>
}
</div>
@if (accelerationResponse?.receiptUrl) {
<div class="d-flex flex-row justify-content-center align-items-center">
<span i18n="accelerator.receipt-label"><a [href]="accelerationResponse.receiptUrl" target="_blank">Click here to get a receipt.</a></span>
</div>
}
</div>
</div>
<hr>

View File

@ -87,6 +87,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
math = Math;
isMobile: boolean = window.innerWidth <= 767.98;
isProdDomain = false;
accelerationResponse: { receiptUrl: string | null } | undefined;
private _step: CheckoutStep = 'summary';
simpleMode: boolean = true;
@ -194,16 +195,12 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.scrollToElement('acceleratePreviewAnchor', 'start');
}
if (changes.accelerating && this.accelerating) {
if (this.step === 'processing' || this.step === 'paid') {
this.moveToStep('success', true);
} else { // Edge case where the transaction gets accelerated by someone else or on another session
this.closeModal();
}
this.moveToStep('success', true);
}
}
moveToStep(step: CheckoutStep, force: boolean = false): void {
if (this.isCheckoutLocked > 0 && !force) {
if (this.isCheckoutLocked > 0 && !force || this.step === 'success') {
return;
}
this.processing = false;
@ -525,7 +522,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
console.error(`Cannot retreive payment card details`);
console.error(`Cannot retrieve payment card details`);
this.accelerateError = 'apple_pay_no_card_details';
this.processing = false;
return;
@ -541,7 +538,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
costUSD
).subscribe({
next: () => {
next: (response) => {
this.accelerationResponse = response;
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
@ -643,7 +641,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
console.error(`Cannot retreive payment card details`);
console.error(`Cannot retrieve payment card details`);
this.accelerateError = 'apple_pay_no_card_details';
this.processing = false;
return;
@ -668,7 +666,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
costUSD,
verificationToken.userChallenged
).subscribe({
next: () => {
next: (response) => {
this.accelerationResponse = response;
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
@ -777,7 +776,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
costUSD,
verificationToken.userChallenged
).subscribe({
next: () => {
next: (response) => {
this.accelerationResponse = response;
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
@ -870,7 +870,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
tokenResult.details.cashAppPay.referenceId,
costUSD
).subscribe({
next: () => {
next: (response) => {
this.accelerationResponse = response;
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
@ -936,7 +937,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.loadingBtcpayInvoice = true;
this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe(
switchMap(response => {
return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId);
return this.servicesApiService.retrieveInvoice$(response.btcpayInvoiceId);
}),
catchError(error => {
console.log(error);

View File

@ -44,6 +44,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
@Input() right: number | string = 10;
@Input() left: number | string = 70;
@Input() widget: boolean = false;
@Input() label: string = '';
@Input() defaultFiat: boolean = false;
@Input() showLegend: boolean = true;
@Input() showYAxis: boolean = true;
@ -55,6 +56,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
hoverData: any[] = [];
conversions: any;
allowZoom: boolean = false;
labelGraphic: any;
selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false };
@ -85,6 +87,18 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
ngOnChanges(changes: SimpleChanges): void {
this.isLoading = true;
this.labelGraphic = this.label ? {
type: 'text',
right: '36px',
bottom: '36px',
z: 100,
silent: true,
style: {
fill: '#fff',
text: this.label,
font: '24px sans-serif'
}
} : undefined;
if (!this.addressSummary$ && (!this.address || !this.stats)) {
return;
}
@ -205,6 +219,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
right: this.adjustedRight,
left: this.adjustedLeft,
},
graphic: this.labelGraphic ? [{
...this.labelGraphic,
right: this.adjustedRight + 22 + 'px',
}] : undefined,
legend: (this.showLegend && !this.stateService.isAnyTestnet()) ? {
data: [
{
@ -443,6 +461,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
right: this.adjustedRight,
left: this.adjustedLeft,
},
graphic: this.labelGraphic ? [{
...this.labelGraphic,
right: this.adjustedRight + 22 + 'px',
}] : undefined,
legend: {
selected: this.selected,
},

View File

@ -36,6 +36,8 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
sent = 0;
totalUnspent = 0;
ogSession: number;
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
@ -58,7 +60,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
.pipe(
switchMap((params: ParamMap) => {
this.rawAddress = params.get('id') || '';
this.openGraphService.waitFor('address-data-' + this.rawAddress);
this.ogSession = this.openGraphService.waitFor('address-data-' + this.rawAddress);
this.error = undefined;
this.isLoadingAddress = true;
this.loadedConfirmedTxCount = 0;
@ -79,7 +81,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
this.isLoadingAddress = false;
this.error = err;
console.log(err);
this.openGraphService.fail('address-data-' + this.rawAddress);
this.openGraphService.fail({ event: 'address-data-' + this.rawAddress, sessionId: this.ogSession });
return of(null);
})
);
@ -97,7 +99,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
this.address = address;
this.updateChainStats();
this.isLoadingAddress = false;
this.openGraphService.waitOver('address-data-' + this.rawAddress);
this.openGraphService.waitOver({ event: 'address-data-' + this.rawAddress, sessionId: this.ogSession });
})
)
.subscribe(() => {},
@ -105,7 +107,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
console.log(error);
this.error = error;
this.isLoadingAddress = false;
this.openGraphService.fail('address-data-' + this.rawAddress);
this.openGraphService.fail({ event: 'address-data-' + this.rawAddress, sessionId: this.ogSession });
}
);
}

View File

@ -49,7 +49,7 @@
</ng-template>
</tr>
</ng-template>
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet' && block?.extras?.pool">
<td i18n="block.miner">Miner</td>
<td *ngIf="stateService.env.MINING_DASHBOARD">
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" style="color: #FFF;padding:0;">

View File

@ -35,6 +35,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
overviewSubscription: Subscription;
networkChangedSubscription: Subscription;
ogSession: number;
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
constructor(
@ -53,8 +55,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
const block$ = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
this.rawId = params.get('id') || '';
this.openGraphService.waitFor('block-viz-' + this.rawId);
this.openGraphService.waitFor('block-data-' + this.rawId);
this.ogSession = this.openGraphService.waitFor('block-viz-' + this.rawId);
this.ogSession = this.openGraphService.waitFor('block-data-' + this.rawId);
const blockHash: string = params.get('id') || '';
this.block = undefined;
@ -86,8 +88,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
catchError((err) => {
this.error = err;
this.seoService.logSoft404();
this.openGraphService.fail('block-data-' + this.rawId);
this.openGraphService.fail('block-viz-' + this.rawId);
this.openGraphService.fail({ event: 'block-data-' + this.rawId, sessionId: this.ogSession });
this.openGraphService.fail({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession });
return of(null);
}),
);
@ -114,7 +116,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
this.isLoadingOverview = true;
this.overviewError = null;
this.openGraphService.waitOver('block-data-' + this.rawId);
this.openGraphService.waitOver({ event: 'block-data-' + this.rawId, sessionId: this.ogSession });
}),
throttleTime(50, asyncScheduler, { leading: true, trailing: true }),
shareReplay({ bufferSize: 1, refCount: true })
@ -129,7 +131,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
.pipe(
catchError((err) => {
this.overviewError = err;
this.openGraphService.fail('block-viz-' + this.rawId);
this.openGraphService.fail({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession });
return of([]);
}),
switchMap((transactions) => {
@ -138,7 +140,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
),
this.stateService.env.ACCELERATOR === true && block.height > 819500
? this.servicesApiService.getAllAccelerationHistory$({ blockHeight: block.height })
.pipe(catchError(() => {
.pipe(
catchError(() => {
return of([]);
}))
: of([])
@ -169,8 +172,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
this.error = error;
this.isLoadingOverview = false;
this.seoService.logSoft404();
this.openGraphService.fail('block-viz-' + this.rawId);
this.openGraphService.fail('block-data-' + this.rawId);
this.openGraphService.fail({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession });
this.openGraphService.fail({ event: 'block-data-' + this.rawId, sessionId: this.ogSession });
if (this.blockGraph) {
this.blockGraph.destroy();
}
@ -196,6 +199,6 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
}
onGraphReady(): void {
this.openGraphService.waitOver('block-viz-' + this.rawId);
this.openGraphService.waitOver({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession });
}
}

View File

@ -238,7 +238,7 @@
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<app-address-graph [address]="widget.props.address" [addressSummary$]="addressSummary$" [period]="widget.props.period || 'all'" [stats]="address ? address.chain_stats : null" [widget]="true" [defaultFiat]="true" [height]="graphHeight"></app-address-graph>
<app-address-graph [address]="widget.props.address" [addressSummary$]="addressSummary$" [period]="widget.props.period || 'all'" [stats]="address ? address.chain_stats : null" [widget]="true" [defaultFiat]="true" [height]="graphHeight" [label]="widget.props.label"></app-address-graph>
</div>
</div>
</div>
@ -272,7 +272,7 @@
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<app-address-graph [addressSummary$]="walletSummary$" [period]="widget.props.period || 'all'" [widget]="true" [height]="graphHeight"></app-address-graph>
<app-address-graph [addressSummary$]="walletSummary$" [period]="widget.props.period || 'all'" [widget]="true" [height]="graphHeight" [label]="widget.props.label"></app-address-graph>
</div>
</div>
</div>

View File

@ -70,11 +70,6 @@
<li class="nav-item" routerLinkActive="active" id="btn-graphs">
<a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a>
</li>
<!--
<li class="nav-item d-none d-lg-block" routerLinkActive="active">
<a class="nav-link" [routerLink]="['/tv' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon></a>
</li>
-->
<li class="nav-item" routerLinkActive="active" id="btn-assets">
<a class="nav-link" [routerLink]="['/assets' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'database']" [fixedWidth]="true" i18n-title="master-page.assets" title="Assets"></fa-icon></a>
</li>

View File

@ -30,6 +30,8 @@ export class PoolPreviewComponent implements OnInit {
slug: string = undefined;
ogSession: number;
constructor(
@Inject(LOCALE_ID) public locale: string,
private apiService: ApiService,
@ -47,22 +49,22 @@ export class PoolPreviewComponent implements OnInit {
this.isLoading = true;
this.imageLoaded = false;
this.slug = slug;
this.openGraphService.waitFor('pool-hash-' + this.slug);
this.openGraphService.waitFor('pool-stats-' + this.slug);
this.openGraphService.waitFor('pool-chart-' + this.slug);
this.openGraphService.waitFor('pool-img-' + this.slug);
this.ogSession = this.openGraphService.waitFor('pool-hash-' + this.slug);
this.ogSession = this.openGraphService.waitFor('pool-stats-' + this.slug);
this.ogSession = this.openGraphService.waitFor('pool-chart-' + this.slug);
this.ogSession = this.openGraphService.waitFor('pool-img-' + this.slug);
return this.apiService.getPoolHashrate$(this.slug)
.pipe(
switchMap((data) => {
this.isLoading = false;
this.prepareChartOptions(data.map(val => [val.timestamp * 1000, val.avgHashrate]));
this.openGraphService.waitOver('pool-hash-' + this.slug);
this.openGraphService.waitOver({ event: 'pool-hash-' + this.slug, sessionId: this.ogSession });
return [slug];
}),
catchError(() => {
this.isLoading = false;
this.seoService.logSoft404();
this.openGraphService.fail('pool-hash-' + this.slug);
this.openGraphService.fail({ event: 'pool-hash-' + this.slug, sessionId: this.ogSession });
return of([slug]);
})
);
@ -72,7 +74,7 @@ export class PoolPreviewComponent implements OnInit {
catchError(() => {
this.isLoading = false;
this.seoService.logSoft404();
this.openGraphService.fail('pool-stats-' + this.slug);
this.openGraphService.fail({ event: 'pool-stats-' + this.slug, sessionId: this.ogSession });
return of(null);
})
);
@ -90,11 +92,11 @@ export class PoolPreviewComponent implements OnInit {
}
poolStats.pool.regexes = regexes.slice(0, -3);
this.openGraphService.waitOver('pool-stats-' + this.slug);
this.openGraphService.waitOver({ event: 'pool-stats-' + this.slug, sessionId: this.ogSession });
const logoSrc = `/resources/mining-pools/` + poolStats.pool.slug + '.svg';
if (logoSrc === this.lastImgSrc) {
this.openGraphService.waitOver('pool-img-' + this.slug);
this.openGraphService.waitOver({ event: 'pool-img-' + this.slug, sessionId: this.ogSession });
}
this.lastImgSrc = logoSrc;
return Object.assign({
@ -103,7 +105,7 @@ export class PoolPreviewComponent implements OnInit {
}),
catchError(() => {
this.isLoading = false;
this.openGraphService.fail('pool-stats-' + this.slug);
this.openGraphService.fail({ event: 'pool-stats-' + this.slug, sessionId: this.ogSession });
return of(null);
})
);
@ -170,16 +172,16 @@ export class PoolPreviewComponent implements OnInit {
}
onChartReady(): void {
this.openGraphService.waitOver('pool-chart-' + this.slug);
this.openGraphService.waitOver({ event: 'pool-chart-' + this.slug, sessionId: this.ogSession });
}
onImageLoad(): void {
this.imageLoaded = true;
this.openGraphService.waitOver('pool-img-' + this.slug);
this.openGraphService.waitOver({ event: 'pool-img-' + this.slug, sessionId: this.ogSession });
}
onImageFail(): void {
this.imageLoaded = false;
this.openGraphService.waitOver('pool-img-' + this.slug);
this.openGraphService.waitOver({ event: 'pool-img-' + this.slug, sessionId: this.ogSession });
}
}

View File

@ -84,74 +84,79 @@ div.scrollable {
text-align: right;
}
}
}
.progress {
background-color: var(--secondary);
.progress {
background-color: var(--secondary);
}
.coinbase {
width: 20%;
@media (max-width: 875px) {
display: none;
}
}
.coinbase {
width: 20%;
@media (max-width: 875px) {
display: none;
}
.health {
@media (max-width: 1150px) {
display: none;
}
}
.height {
width: 10%;
.height {
width: 10%;
}
.timestamp {
@media (max-width: 875px) {
padding-left: 50px;
}
.timestamp {
@media (max-width: 875px) {
padding-left: 50px;
}
@media (max-width: 685px) {
display: none;
}
@media (max-width: 625px) {
display: none;
}
}
.mined {
width: 13%;
@media (max-width: 1100px) {
display: none;
}
.mined {
width: 13%;
}
.txs {
@media (max-width: 938px) {
display: none;
}
.txs {
padding-right: 40px;
@media (max-width: 1100px) {
padding-right: 10px;
}
@media (max-width: 875px) {
padding-right: 20px;
}
@media (max-width: 567px) {
padding-right: 10px;
}
padding-right: 40px;
@media (max-width: 1100px) {
padding-right: 10px;
}
.size {
width: 12%;
@media (max-width: 1000px) {
width: 15%;
}
@media (max-width: 875px) {
width: 20%;
}
@media (max-width: 650px) {
width: 20%;
}
@media (max-width: 450px) {
display: none;
}
@media (max-width: 875px) {
padding-right: 20px;
}
@media (max-width: 567px) {
padding-right: 10px;
}
}
.scriptmessage {
overflow: hidden;
display: inline-block;
text-overflow: ellipsis;
vertical-align: middle;
width: auto;
text-align: left;
.size {
min-width: 80px;
width: 12%;
@media (max-width: 1000px) {
width: 15%;
}
}
.scriptmessage {
max-width: 340px;
overflow: hidden;
display: inline-block;
text-overflow: ellipsis;
vertical-align: middle;
width: auto;
text-align: left;
}
.reward {
@media (max-width: 1035px) {
display: none;
}
}

View File

@ -45,11 +45,11 @@
<br>
<h4>USING MEMPOOL ACCELERATOR&trade;</h4>
<h4>USING MEMPOOL ACCELERATOR&reg;</h4>
<p *ngIf="officialMempoolSpace">If you use Mempool Accelerator&trade; your acceleration request will be sent to us and relayed to Mempool's mining pool partners. We will store the TXID of the transactions you accelerate with us. We share this information with our mining pool partners, and publicly display accelerated transaction details on our website and APIs. No personal information or account identifiers will be shared with any third party including mining pool partners.</p>
<p *ngIf="officialMempoolSpace">If you use Mempool Accelerator&reg; your acceleration request will be sent to us and relayed to Mempool's mining pool partners. We will store the TXID of the transactions you accelerate with us. We share this information with our mining pool partners, and publicly display accelerated transaction details on our website and APIs. No personal information or account identifiers will be shared with any third party including mining pool partners.</p>
<p *ngIf="!officialMempoolSpace">When using Mempool Accelerator&trade; the mempool.space privacy policy will apply: <a href="https://mempool.space/privacy-policy">https://mempool.space/privacy-policy</a>.</p>
<p *ngIf="!officialMempoolSpace">When using Mempool Accelerator&reg; the mempool.space privacy policy will apply: <a href="https://mempool.space/privacy-policy">https://mempool.space/privacy-policy</a>.</p>
<br>
@ -67,7 +67,7 @@
<li>If you sign up for a subscription to Mempool Enterprise&trade; we also collect your company name which is not shared with any third-party.</li>
<li>If you sign up for an account on mempool.space and use Mempool Accelerator&trade; Pro your accelerated transactions will be associated with your account for the purposes of accounting.</li>
<li>If you sign up for an account on mempool.space and use Mempool Accelerator&reg; Pro your accelerated transactions will be associated with your account for the purposes of accounting.</li>
</ul>
@ -101,7 +101,7 @@
<p>We aim to retain your data only as long as necessary:</p>
<ul>
<li>An account is considered inactive if all of the following conditions are met: a) No login activity within the past 6 months, b) No active subscriptions associated with the account, c) No Mempool Accelerator Pro account credit</li>
<li>An account is considered inactive if all of the following conditions are met: a) No login activity within the past 6 months, b) No active subscriptions associated with the account, c) No Mempool Accelerator® Pro account credit</li>
<li>If an account meets the criteria for inactivity as defined above, we will automatically delete the associated account data after a period of 6 months of continuous inactivity, except in the case of payment disputes or account irregularities.</li>
</ul>

View File

@ -194,14 +194,16 @@ export class StartComponent implements OnInit, AfterViewChecked, OnDestroy {
applyScrollLeft(): void {
if (this.blockchainContainer?.nativeElement?.scrollWidth) {
let lastScrollLeft = null;
while (this.scrollLeft < 0 && this.shiftPagesForward() && lastScrollLeft !== this.scrollLeft) {
lastScrollLeft = this.scrollLeft;
this.scrollLeft += this.pageWidth;
}
lastScrollLeft = null;
while (this.scrollLeft > this.blockchainContainer.nativeElement.scrollWidth && this.shiftPagesBack() && lastScrollLeft !== this.scrollLeft) {
lastScrollLeft = this.scrollLeft;
this.scrollLeft -= this.pageWidth;
if (!this.timeLtr) {
while (this.scrollLeft < 0 && this.shiftPagesForward() && lastScrollLeft !== this.scrollLeft) {
lastScrollLeft = this.scrollLeft;
this.scrollLeft += this.pageWidth;
}
lastScrollLeft = null;
while (this.scrollLeft > this.blockchainContainer.nativeElement.scrollWidth && this.shiftPagesBack() && lastScrollLeft !== this.scrollLeft) {
lastScrollLeft = this.scrollLeft;
this.scrollLeft -= this.pageWidth;
}
}
this.blockchainContainer.nativeElement.scrollLeft = this.scrollLeft;
}

View File

@ -16,9 +16,6 @@
<a class="btn btn-primary btn-sm mb-0" [routerLink]="['/clock/mempool/0' | relativeUrl]" style="color: white" id="btn-clock">
<fa-icon [icon]="['fas', 'clock']" [fixedWidth]="true" i18n-title="master-page.clockview" i18n-title="footer.clock-mempool" title="Clock (Mempool)"></fa-icon>
</a>
<a *ngIf="!isMobile()" class="btn btn-primary btn-sm mb-0" [routerLink]="['/tv' | relativeUrl]" style="color: white" id="btn-tv">
<fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon>
</a>
</div>
<div class="btn-toggle-rows" name="radioBasic">
<div class="btn-group btn-group-toggle">

File diff suppressed because one or more lines are too long

View File

@ -1,25 +0,0 @@
<div id="tv-wrapper">
<div class="tv-container">
<div class="chart-holder">
<app-mempool-graph
[template]="'advanced'"
[height]="600"
[left]="60"
[right]="10"
[data]="statsSubscription$ | async"
[showZoom]="false"
></app-mempool-graph>
</div>
<div class="blockchain-wrapper" [dir]="timeLtr ? 'rtl' : 'ltr'" [class.time-ltr]="timeLtr">
<div class="position-container">
<span>
<div class="blocks-wrapper">
<app-mempool-blocks></app-mempool-blocks>
<app-blockchain-blocks></app-blockchain-blocks>
</div>
<div id="divider"></div>
</span>
</div>
</div>
</div>
</div>

View File

@ -1,80 +0,0 @@
.loading {
margin: auto;
width: 100%;
display: flex;
text-align: center;
justify-content: center;
height: 100vh;
align-items: center;
}
#tv-wrapper {
height: 100vh;
overflow: hidden;
position: relative;
}
.chart-holder {
position: relative;
height: 655px;
width: 100%;
margin: 30px auto 0;
}
.blockchain-wrapper {
display: block;
height: 100%;
min-height: 240px;
position: relative;
top: 30px;
.position-container {
position: absolute;
left: 0;
bottom: 170px;
transform: translateX(50vw);
}
#divider {
width: 2px;
height: 175px;
left: 0;
top: -40px;
position: absolute;
img {
position: absolute;
left: -100px;
top: -28px;
}
}
&.time-ltr {
.blocks-wrapper {
transform: scaleX(-1);
}
}
}
:host-context(.ltr-layout) {
.blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: ltr;
}
}
:host-context(.rtl-layout) {
.blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: rtl;
}
}
.tv-container {
display: flex;
margin-top: 0px;
flex-direction: column;
}

View File

@ -1,86 +0,0 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { WebsocketService } from '@app/services/websocket.service';
import { OptimizedMempoolStats } from '@interfaces/node-api.interface';
import { StateService } from '@app/services/state.service';
import { ApiService } from '@app/services/api.service';
import { SeoService } from '@app/services/seo.service';
import { ActivatedRoute } from '@angular/router';
import { map, scan, startWith, switchMap, tap } from 'rxjs/operators';
import { interval, merge, Observable, Subscription } from 'rxjs';
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-television',
templateUrl: './television.component.html',
styleUrls: ['./television.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TelevisionComponent implements OnInit, OnDestroy {
mempoolStats: OptimizedMempoolStats[] = [];
statsSubscription$: Observable<OptimizedMempoolStats[]>;
fragment: string;
timeLtrSubscription: Subscription;
timeLtr: boolean = this.stateService.timeLtr.value;
constructor(
private websocketService: WebsocketService,
private apiService: ApiService,
private stateService: StateService,
private seoService: SeoService,
private route: ActivatedRoute
) { }
refreshStats(time: number, fn: Observable<OptimizedMempoolStats[]>) {
return interval(time).pipe(startWith(0), switchMap(() => fn));
}
ngOnInit() {
this.seoService.setTitle($localize`:@@46ce8155c9ab953edeec97e8950b5a21e67d7c4e:TV view`);
this.seoService.setDescription($localize`:@@meta.description.tv:See Bitcoin blocks and mempool congestion in real-time in a simplified format perfect for a TV.`);
this.websocketService.want(['blocks', 'live-2h-chart', 'mempool-blocks']);
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !!ltr;
});
this.statsSubscription$ = merge(
this.stateService.live2Chart$.pipe(map(stats => [stats])),
this.route.fragment
.pipe(
tap(fragment => { this.fragment = fragment ?? '2h'; }),
switchMap((fragment) => {
const minute = 60000; const hour = 3600000;
switch (fragment) {
case '24h': return this.apiService.list24HStatistics$();
case '1w': return this.refreshStats(5 * minute, this.apiService.list1WStatistics$());
case '1m': return this.refreshStats(30 * minute, this.apiService.list1MStatistics$());
case '3m': return this.refreshStats(2 * hour, this.apiService.list3MStatistics$());
case '6m': return this.refreshStats(3 * hour, this.apiService.list6MStatistics$());
case '1y': return this.refreshStats(8 * hour, this.apiService.list1YStatistics$());
case '2y': return this.refreshStats(8 * hour, this.apiService.list2YStatistics$());
case '3y': return this.refreshStats(12 * hour, this.apiService.list3YStatistics$());
default /* 2h */: return this.apiService.list2HStatistics$();
}
})
)
)
.pipe(
scan((mempoolStats, newStats) => {
if (newStats.length > 1) {
mempoolStats = newStats;
} else if (['2h', '24h'].includes(this.fragment)) {
mempoolStats.unshift(newStats[0]);
const now = Date.now() / 1000;
const start = now - (this.fragment === '2h' ? (2 * 60 * 60) : (24 * 60 * 60) );
mempoolStats = mempoolStats.filter(p => p.added >= start);
}
return mempoolStats;
})
);
}
ngOnDestroy() {
this.timeLtrSubscription.unsubscribe();
}
}

View File

@ -67,9 +67,9 @@
</ng-container>
<h4>MEMPOOL ACCELERATOR&trade;</h4>
<h4>MEMPOOL ACCELERATOR&reg;</h4>
<p><a href="https://mempool.space/accelerator">Mempool Accelerator&trade;</a> enables members of the Bitcoin community to submit requests for transaction prioritization. </p>
<p><a href="https://mempool.space/accelerator">Mempool Accelerator&reg;</a> enables members of the Bitcoin community to submit requests for transaction prioritization. </p>
<ul>
<li>Mempool will use reasonable commercial efforts to relay user acceleration requests to Mempool's mining pool partners, but it is at the discretion of Mempool and Mempool's mining pool partners to accept acceleration requests. </li>
@ -84,11 +84,11 @@
<br>
<li>All acceleration payments and Mempool Accelerator&trade; account credit top-ups are non-refundable. </li>
<li>All acceleration payments and Mempool Accelerator&reg; account credit top-ups are non-refundable. </li>
<br>
<li>Mempool Accelerator&trade; account credit top-ups are prepayment for future accelerations and cannot be withdrawn or transferred.</li>
<li>Mempool Accelerator&reg; account credit top-ups are prepayment for future accelerations and cannot be withdrawn or transferred.</li>
<br>

View File

@ -8,7 +8,7 @@
<div *ngIf="officialMempoolSpace">
<h2>Trademark Policy and Guidelines</h2>
<h5>The Mempool Open Source Project &reg;</h5>
<h6>Updated: August 19, 2024</h6>
<h6>Updated: February 11, 2025</h6>
<br>
<div class="text-left">
@ -59,6 +59,7 @@
<tr><td>Mempool Accelerator</td></tr>
<tr><td>Mempool Enterprise</td></tr>
<tr><td>Mempool Liquidity</td></tr>
<tr><td>Mempool</td></tr>
<tr><td>mempool.space</td></tr>
<tr><td>Be your own explorer</td></tr>
<tr><td>Explore the full Bitcoin ecosystem</td></tr>
@ -340,7 +341,8 @@
<p>Also, if you are using our Marks in a way described in the sections "Uses for Which We Are Granting a License," you must include the following trademark attribution at the foot of the webpage where you have used the Mark (or, if in a book, on the credits page), on any packaging or labeling, and on advertising or marketing materials:</p>
<p>"The Mempool Open Source Project&reg;, Mempool Accelerator&trade;, Mempool Enterprise&reg;, Mempool Liquidity&trade;, mempool.space&reg;, Be your own explorer&trade;, Explore the full Bitcoin ecosystem&reg;, Mempool Goggles&trade;, the mempool logo;, the mempool Square logo;, the mempool Blocks logo;, the mempool Blocks 3 | 2 logo;, the mempool.space Vertical Logo;, the Mempool Accelerator logo;, the Mempool Goggles 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, and are used with permission. Mempool Space K.K. has no affiliation with and does not sponsor or endorse the information provided herein."</p>
<p>"The Mempool Open Source Project&reg;, Mempool Accelerator&reg;, Mempool Enterprise&reg;, Mempool Liquidity&trade;, Mempool&reg;, mempool.space&reg;, Be your own explorer&trade;, Explore the full Bitcoin ecosystem&reg;, Mempool Goggles&trade;, the mempool logo;, the mempool Square logo;, the mempool Blocks logo;, the mempool Blocks 3 | 2 logo;, the mempool.space Vertical Logo;, the Mempool Accelerator logo;, the Mempool Goggles 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, and are used with permission. Mempool Space K.K. has no affiliation with and does not sponsor or endorse the information provided herein."</p>
<li>What to Do When You See Abuse</li>
<br>

View File

@ -0,0 +1,56 @@
<br>
<div class="title">
<h2 class="text-left" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
</div>
<div class="box cpfp-details">
<table class="table table-fixed table-borderless table-striped">
<thead>
<tr>
<th i18n="transactions-list.vout.scriptpubkey-type">Type</th>
<th class="txids" i18n="dashboard.latest-transactions.txid">TXID</th>
<th *only-vsize class="d-none d-lg-table-cell" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</th>
<th *only-weight class="d-none d-lg-table-cell" i18n="transaction.weight|Transaction Weight">Weight</th>
<th i18n="transaction.fee-rate|Transaction fee rate">Fee rate</th>
<th class="d-none d-lg-table-cell"></th>
</tr>
</thead>
<tbody>
<ng-template [ngIf]="cpfpInfo?.descendants?.length">
<tr *ngFor="let cpfpTx of cpfpInfo.descendants">
<td><span class="badge badge-primary" i18n="transaction.descendant|Descendant">Descendant</span></td>
<td>
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
</td>
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) > roundToOneDecimal(tx)" class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
</tr>
</ng-template>
<ng-template [ngIf]="cpfpInfo?.bestDescendant">
<tr>
<td><span class="badge badge-success" i18n="transaction.descendant|Descendant">Descendant</span></td>
<td class="txids">
<app-truncate [text]="cpfpInfo.bestDescendant.txid" [link]="['/tx' | relativeUrl, cpfpInfo.bestDescendant.txid]"></app-truncate>
</td>
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight / 4 | vbytes: 2"></td>
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight | wuBytes: 2"></td>
<td><app-fee-rate [fee]="cpfpInfo.bestDescendant.fee" [weight]="cpfpInfo.bestDescendant.weight"></app-fee-rate></td>
<td class="d-none d-lg-table-cell"><fa-icon class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
</tr>
</ng-template>
<ng-template [ngIf]="cpfpInfo?.ancestors?.length">
<tr *ngFor="let cpfpTx of cpfpInfo.ancestors">
<td><span class="badge badge-primary" i18n="transaction.ancestor|Transaction Ancestor">Ancestor</span></td>
<td class="txids">
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
</td>
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) < roundToOneDecimal(tx)" class="arrow-red" [icon]="['fas', 'angle-double-down']" [fixedWidth]="true"></fa-icon></td>
</tr>
</ng-template>
</tbody>
</table>
</div>

View File

@ -0,0 +1,32 @@
.title {
h2 {
line-height: 1;
margin: 0;
padding-bottom: 5px;
}
}
.cpfp-details {
.txids {
width: 60%;
}
@media (max-width: 500px) {
.txids {
width: 40%;
}
}
}
.arrow-green {
color: var(--success);
}
.arrow-red {
color: var(--red);
}
.badge {
position: relative;
top: -1px;
}

View File

@ -0,0 +1,22 @@
import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core';
import { CpfpInfo } from '@interfaces/node-api.interface';
import { Transaction } from '@interfaces/electrs.interface';
@Component({
selector: 'app-cpfp-info',
templateUrl: './cpfp-info.component.html',
styleUrls: ['./cpfp-info.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CpfpInfoComponent implements OnInit {
@Input() cpfpInfo: CpfpInfo;
@Input() tx: Transaction;
constructor() {}
ngOnInit(): void {}
roundToOneDecimal(cpfpTx: any): number {
return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1);
}
}

View File

@ -153,7 +153,7 @@
<ng-template #etaRow>
@if (!isLoadingTx) {
@if (!replaced && !isCached) {
@if (!replaced && !isCached && !unbroadcasted) {
<tr>
<td class="td-width align-items-center align-middle" i18n="transaction.eta|Transaction ETA">ETA</td>
<td>
@ -170,7 +170,7 @@
@if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary && notAcceleratedOnLoad) {
<div class="d-flex accelerate">
<a class="btn btn-sm accelerateDeepMempool btn-small-height" [class.disabled]="!eligibleForAcceleration" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a>
<a *ngIf="!eligibleForAcceleration" href="https://mempool.space/accelerator#why-cant-accelerate" target="_blank" class="info-badges ml-1" i18n-ngbTooltip="Mempool Accelerator&trade; tooltip" ngbTooltip="This transaction cannot be accelerated">
<a *ngIf="!eligibleForAcceleration" href="https://mempool.space/accelerator#why-cant-accelerate" target="_blank" class="info-badges ml-1" i18n-ngbTooltip="Mempool Accelerator&reg; tooltip" ngbTooltip="This transaction cannot be accelerated">
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
</a>
</div>
@ -184,7 +184,7 @@
</td>
</tr>
}
} @else {
} @else if (!unbroadcasted){
<ng-container *ngTemplateOutlet="skeletonDetailsRow"></ng-container>
}
</ng-template>
@ -213,11 +213,11 @@
@if (!isLoadingTx) {
<tr>
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
<td class="text-wrap">{{ tx.fee | number }} <span class="symbol" i18n="shared.sats">sats</span>
<td class="text-wrap">{{ (tx.fee | number) ?? '-' }} <span class="symbol" i18n="shared.sats">sats</span>
@if (isAcceleration && accelerationInfo?.bidBoost ?? tx.feeDelta > 0) {
<span class="oobFees" i18n-ngbTooltip="Acceleration Fees" ngbTooltip="Acceleration fees paid out-of-band"> +{{ accelerationInfo?.bidBoost ?? tx.feeDelta | number }} </span><span class="symbol" i18n="shared.sats">sats</span>
}
<span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee + (isAcceleration ? ((accelerationInfo?.bidBoost ?? tx.feeDelta) || 0) : 0)"></app-fiat></span>
<span class="fiat"><app-fiat *ngIf="tx.fee >= 0" [blockConversion]="tx.price" [value]="tx.fee + (isAcceleration ? ((accelerationInfo?.bidBoost ?? tx.feeDelta) || 0) : 0)"></app-fiat></span>
</td>
</tr>
} @else {

View File

@ -38,6 +38,7 @@ export class TransactionDetailsComponent implements OnInit {
@Input() replaced: boolean;
@Input() isCached: boolean;
@Input() ETA$: Observable<ETA>;
@Input() unbroadcasted: boolean;
@Output() accelerateClicked = new EventEmitter<boolean>();
@Output() toggleCpfp$ = new EventEmitter<void>();

View File

@ -43,6 +43,8 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
opReturns: Vout[];
extraData: 'none' | 'coinbase' | 'opreturn';
ogSession: number;
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
@ -75,7 +77,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
)
.subscribe((cpfpInfo) => {
this.cpfpInfo = cpfpInfo;
this.openGraphService.waitOver('cpfp-data-' + this.txId);
this.openGraphService.waitOver({ event: 'cpfp-data-' + this.txId, sessionId: this.ogSession });
});
this.subscription = this.route.paramMap
@ -83,8 +85,8 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
switchMap((params: ParamMap) => {
const urlMatch = (params.get('id') || '').split(':');
this.txId = urlMatch[0];
this.openGraphService.waitFor('tx-data-' + this.txId);
this.openGraphService.waitFor('tx-time-' + this.txId);
this.ogSession = this.openGraphService.waitFor('tx-data-' + this.txId);
this.ogSession = this.openGraphService.waitFor('tx-time-' + this.txId);
this.seoService.setTitle(
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
);
@ -138,7 +140,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
.subscribe((tx: Transaction) => {
if (!tx) {
this.seoService.logSoft404();
this.openGraphService.fail('tx-data-' + this.txId);
this.openGraphService.fail({ event: 'tx-data-' + this.txId, sessionId: this.ogSession });
return;
}
@ -155,10 +157,10 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
if (tx.status.confirmed) {
this.transactionTime = tx.status.block_time;
this.openGraphService.waitOver('tx-time-' + this.txId);
this.openGraphService.waitOver({ event: 'tx-time-' + this.txId, sessionId: this.ogSession });
} else if (!tx.status.confirmed && tx.firstSeen) {
this.transactionTime = tx.firstSeen;
this.openGraphService.waitOver('tx-time-' + this.txId);
this.openGraphService.waitOver({ event: 'tx-time-' + this.txId, sessionId: this.ogSession });
} else {
this.getTransactionTime();
}
@ -184,11 +186,11 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
}
}
this.openGraphService.waitOver('tx-data-' + this.txId);
this.openGraphService.waitOver({ event: 'tx-data-' + this.txId, sessionId: this.ogSession });
},
(error) => {
this.seoService.logSoft404();
this.openGraphService.fail('tx-data-' + this.txId);
this.openGraphService.fail({ event: 'tx-data-' + this.txId, sessionId: this.ogSession });
this.error = error;
this.isLoadingTx = false;
}
@ -205,7 +207,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
)
.subscribe((transactionTimes) => {
this.transactionTime = transactionTimes[0];
this.openGraphService.waitOver('tx-time-' + this.txId);
this.openGraphService.waitOver({ event: 'tx-time-' + this.txId, sessionId: this.ogSession });
});
}

View File

@ -0,0 +1,215 @@
<div class="container-xl">
@if (!transaction) {
<h1 style="margin-top: 19px;" i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</h1>
<form [formGroup]="pushTxForm" (submit)="decodeTransaction()" novalidate>
<div class="mb-3">
<textarea formControlName="txRaw" class="form-control" rows="5" i18n-placeholder="transaction.hex-and-psbt" placeholder="Transaction hex or base64 encoded PSBT"></textarea>
</div>
<button [disabled]="isLoading" type="submit" class="btn btn-primary mr-2" i18n="shared.preview|Preview">Preview</button>
<input type="checkbox" [checked]="!offlineMode" id="offline-mode" (change)="onOfflineModeChange($event)">
<label class="label" for="offline-mode">
<span i18n="transaction.fetch-prevout-data">Fetch missing prevouts</span>
</label>
<p *ngIf="error" class="red-color d-inline">Error decoding transaction, reason: {{ error }}</p>
</form>
}
@if (transaction && !error && !isLoading) {
<div class="title-block">
<h1 i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</h1>
<span class="tx-link">
<span class="txid">
<app-truncate [text]="transaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, transaction.txid]" [disabled]="!successBroadcast">
<app-clipboard [text]="transaction.txid"></app-clipboard>
</app-truncate>
</span>
</span>
<div class="container-buttons">
<button class="btn btn-sm" style="margin-left: 10px; padding: 0;" (click)="resetForm()">&#10005;</button>
</div>
</div>
<p class="red-color d-inline">{{ errorBroadcast }}</p>
<div class="clearfix"></div>
<div class="alert alert-mempool" style="align-items: center;">
<span>
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
<ng-container *ngIf="!successBroadcast" i18n="transaction.local-tx|This transaction is stored locally in your browser.">
This transaction is stored locally in your browser. Broadcast it to add it to the mempool.
</ng-container>
<ng-container *ngIf="successBroadcast" i18n="transaction.redirecting|Redirecting to transaction page...">
Redirecting to transaction page...
</ng-container>
</span>
<button *ngIf="!successBroadcast" [disabled]="isLoadingBroadcast" type="button" class="btn btn-sm btn-primary btn-broadcast" i18n="transaction.broadcast|Broadcast" (click)="postTx()">Broadcast</button>
<button *ngIf="successBroadcast" type="button" class="btn btn-sm btn-success no-cursor btn-broadcast" i18n="transaction.broadcasted|Broadcasted">Broadcasted</button>
</div>
@if (!hasPrevouts) {
<div class="alert alert-mempool">
@if (offlineMode) {
<span><strong>Missing prevouts are not loaded. Some fields like fee rate cannot be calculated.</strong></span>
} @else {
<span><strong>Error loading missing prevouts</strong>. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }}</span>
}
</div>
}
@if (errorCpfpInfo) {
<div class="alert alert-mempool">
<span><strong>Error loading CPFP data</strong>. Reason: {{ errorCpfpInfo }}</span>
</div>
}
<app-transaction-details
[unbroadcasted]="true"
[network]="stateService.network"
[tx]="transaction"
[isLoadingTx]="false"
[isMobile]="isMobile"
[isLoadingFirstSeen]="false"
[featuresEnabled]="true"
[filters]="filters"
[hasEffectiveFeeRate]="hasEffectiveFeeRate"
[cpfpInfo]="cpfpInfo"
[hasCpfp]="hasCpfp"
(toggleCpfp$)="this.showCpfpDetails = !this.showCpfpDetails"
></app-transaction-details>
<app-cpfp-info *ngIf="showCpfpDetails" [cpfpInfo]="cpfpInfo" [tx]="transaction"></app-cpfp-info>
<br>
<ng-container *ngIf="flowEnabled; else flowPlaceholder">
<div class="title float-left">
<h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2>
</div>
<button type="button" class="btn btn-outline-info flow-toggle btn-sm float-right" (click)="toggleGraph()" i18n="hide-diagram">Hide diagram</button>
<div class="clearfix"></div>
<div class="box">
<div class="graph-container" #graphContainer>
<tx-bowtie-graph
[tx]="transaction"
[cached]="true"
[width]="graphWidth"
[height]="graphHeight"
[lineLimit]="inOutLimit"
[maxStrands]="graphExpanded ? maxInOut : 24"
[network]="stateService.network"
[tooltip]="true"
[connectors]="true"
[inputIndex]="null" [outputIndex]="null"
>
</tx-bowtie-graph>
</div>
<div class="toggle-wrapper" *ngIf="maxInOut > 24">
<button class="btn btn-sm btn-primary graph-toggle" (click)="expandGraph();" *ngIf="!graphExpanded; else collapseBtn"><span i18n="show-more">Show more</span></button>
<ng-template #collapseBtn>
<button class="btn btn-sm btn-primary graph-toggle" (click)="collapseGraph();"><span i18n="show-less">Show less</span></button>
</ng-template>
</div>
</div>
<br>
</ng-container>
<ng-template #flowPlaceholder>
<div class="box hidden">
<div class="graph-container" #graphContainer>
</div>
</div>
</ng-template>
<div class="subtitle-block">
<div class="title">
<h2 i18n="transaction.inputs-and-outputs|Transaction inputs and outputs">Inputs & Outputs</h2>
</div>
<div class="title-buttons">
<button *ngIf="!flowEnabled" type="button" class="btn btn-outline-info flow-toggle btn-sm" (click)="toggleGraph()" i18n="show-diagram">Show diagram</button>
<button type="button" class="btn btn-outline-info btn-sm" (click)="txList.toggleDetails()" i18n="transaction.details|Transaction Details">Details</button>
</div>
</div>
<app-transactions-list #txList [transactions]="[transaction]" [transactionPage]="true" [txPreview]="true"></app-transactions-list>
<div class="title text-left">
<h2 i18n="transaction.details|Transaction Details">Details</h2>
</div>
<div class="box">
<div class="row">
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td i18n="block.size">Size</td>
<td [innerHTML]="'&lrm;' + (transaction.size | bytes: 2)"></td>
</tr>
<tr>
<td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
<td [innerHTML]="'&lrm;' + (transaction.weight / 4 | vbytes: 2)"></td>
</tr>
<tr *ngIf="adjustedVsize">
<td><ng-container i18n="transaction.adjusted-vsize|Transaction Adjusted VSize">Adjusted vsize</ng-container>
<a class="info-link" [routerLink]="['/docs/faq/' | relativeUrl]" fragment="what-is-adjusted-vsize">
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
</a>
</td>
<td [innerHTML]="'&lrm;' + (adjustedVsize | vbytes: 2)"></td>
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (transaction.weight | wuBytes: 2)"></td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td i18n="transaction.version">Version</td>
<td [innerHTML]="'&lrm;' + (transaction.version | number)"></td>
</tr>
<tr>
<td i18n="transaction.locktime">Locktime</td>
<td [innerHTML]="'&lrm;' + (transaction.locktime | number)"></td>
</tr>
<tr *ngIf="transaction.sigops >= 0">
<td><ng-container i18n="transaction.sigops|Transaction Sigops">Sigops</ng-container>
<a class="info-link" [routerLink]="['/docs/faq/' | relativeUrl]" fragment="what-are-sigops">
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
</a>
</td>
<td [innerHTML]="'&lrm;' + (transaction.sigops | number)"></td>
</tr>
<tr>
<td i18n="transaction.hex">Transaction hex</td>
<td><app-clipboard [text]="rawHexTransaction" [leftPadding]="false"></app-clipboard></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
}
@if (isLoading) {
<div class="text-center">
<div class="spinner-border text-light mt-2 mb-2"></div>
<h3 i18n="transaction.error.loading-prevouts">
Loading {{ isLoadingPrevouts ? 'transaction prevouts' : isLoadingCpfpInfo ? 'CPFP' : '' }}
</h3>
</div>
}
</div>

View File

@ -0,0 +1,202 @@
.label {
margin: 0 5px;
}
.container-buttons {
align-self: center;
}
.title-block {
flex-wrap: wrap;
align-items: baseline;
@media (min-width: 650px) {
flex-direction: row;
}
h1 {
margin: 0rem;
margin-right: 15px;
line-height: 1;
}
}
.tx-link {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: baseline;
width: 0;
max-width: 100%;
margin-right: 0px;
margin-bottom: 0px;
margin-top: 8px;
@media (min-width: 651px) {
flex-grow: 1;
margin-bottom: 0px;
margin-right: 1em;
top: 1px;
position: relative;
}
@media (max-width: 650px) {
width: 100%;
order: 3;
}
.txid {
width: 200px;
min-width: 200px;
flex-grow: 1;
}
}
.container-xl {
margin-bottom: 40px;
}
.row {
flex-direction: column;
@media (min-width: 850px) {
flex-direction: row;
}
}
.box.hidden {
visibility: hidden;
height: 0px;
padding-top: 0px;
padding-bottom: 0px;
margin-top: 0px;
margin-bottom: 0px;
}
.graph-container {
position: relative;
width: 100%;
background: var(--stat-box-bg);
padding: 10px 0;
padding-bottom: 0;
}
.toggle-wrapper {
width: 100%;
text-align: center;
margin: 1.25em 0 0;
}
.graph-toggle {
margin: auto;
}
.table {
tr td {
padding: 0.75rem 0.5rem;
@media (min-width: 576px) {
padding: 0.75rem 0.75rem;
}
&:last-child {
text-align: right;
@media (min-width: 850px) {
text-align: left;
}
}
.btn {
display: block;
}
&.wrap-cell {
white-space: normal;
}
}
}
.effective-fee-container {
display: block;
@media (min-width: 768px){
display: inline-block;
}
@media (max-width: 425px){
display: flex;
flex-direction: column;
}
}
.effective-fee-rating {
@media (max-width: 767px){
margin-right: 0px !important;
}
}
.title {
h2 {
line-height: 1;
margin: 0;
padding-bottom: 5px;
}
}
.btn-outline-info {
margin-top: 5px;
@media (min-width: 768px){
margin-top: 0px;
}
}
.flow-toggle {
margin-top: -5px;
margin-left: 10px;
@media (min-width: 768px){
display: inline-block;
margin-top: 0px;
margin-bottom: 0px;
}
}
.subtitle-block {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
.title {
flex-shrink: 0;
}
.title-buttons {
flex-shrink: 1;
text-align: right;
.btn {
margin-top: 0;
margin-bottom: 8px;
margin-left: 8px;
}
}
}
.cpfp-details {
.txids {
width: 60%;
}
@media (max-width: 500px) {
.txids {
width: 40%;
}
}
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.no-cursor {
cursor: default !important;
pointer-events: none;
}
.btn-broadcast {
margin-left: 5px;
@media (max-width: 567px) {
margin-left: 0;
margin-top: 5px;
}
}

View File

@ -0,0 +1,320 @@
import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy } from '@angular/core';
import { Transaction, Vout } from '@interfaces/electrs.interface';
import { StateService } from '../../services/state.service';
import { Filter, toFilters } from '../../shared/filters.utils';
import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops } from '../../shared/transaction.utils';
import { catchError, firstValueFrom, Subscription, switchMap, tap, throwError, timer } from 'rxjs';
import { WebsocketService } from '../../services/websocket.service';
import { ActivatedRoute, Router } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { SeoService } from '../../services/seo.service';
import { seoDescriptionNetwork } from '@app/shared/common.utils';
import { ApiService } from '../../services/api.service';
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
import { CpfpInfo } from '../../interfaces/node-api.interface';
@Component({
selector: 'app-transaction-raw',
templateUrl: './transaction-raw.component.html',
styleUrls: ['./transaction-raw.component.scss'],
})
export class TransactionRawComponent implements OnInit, OnDestroy {
pushTxForm: UntypedFormGroup;
rawHexTransaction: string;
isLoading: boolean;
isLoadingPrevouts: boolean;
isLoadingCpfpInfo: boolean;
offlineMode: boolean = false;
transaction: Transaction;
error: string;
errorPrevouts: string;
errorCpfpInfo: string;
hasPrevouts: boolean;
missingPrevouts: string[];
isLoadingBroadcast: boolean;
errorBroadcast: string;
successBroadcast: boolean;
broadcastSubscription: Subscription;
isMobile: boolean;
@ViewChild('graphContainer')
graphContainer: ElementRef;
graphExpanded: boolean = false;
graphWidth: number = 1068;
graphHeight: number = 360;
inOutLimit: number = 150;
maxInOut: number = 0;
flowPrefSubscription: Subscription;
hideFlow: boolean = this.stateService.hideFlow.value;
flowEnabled: boolean;
adjustedVsize: number;
filters: Filter[] = [];
hasEffectiveFeeRate: boolean;
fetchCpfp: boolean;
cpfpInfo: CpfpInfo | null;
hasCpfp: boolean = false;
showCpfpDetails = false;
mempoolBlocksSubscription: Subscription;
constructor(
public route: ActivatedRoute,
public router: Router,
public stateService: StateService,
public electrsApi: ElectrsApiService,
public websocketService: WebsocketService,
public formBuilder: UntypedFormBuilder,
public seoService: SeoService,
public apiService: ApiService,
public relativeUrlPipe: RelativeUrlPipe,
) {}
ngOnInit(): void {
this.seoService.setTitle($localize`:@@meta.title.preview-tx:Preview Transaction`);
this.seoService.setDescription($localize`:@@meta.description.preview-tx:Preview a transaction to the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network using the transaction's raw hex data.`);
this.websocketService.want(['blocks', 'mempool-blocks']);
this.pushTxForm = this.formBuilder.group({
txRaw: ['', Validators.required],
});
}
async decodeTransaction(): Promise<void> {
this.resetState();
this.isLoading = true;
try {
const { tx, hex } = decodeRawTransaction(this.pushTxForm.get('txRaw').value.trim(), this.stateService.network);
await this.fetchPrevouts(tx);
await this.fetchCpfpInfo(tx);
this.processTransaction(tx, hex);
} catch (error) {
this.error = error.message;
} finally {
this.isLoading = false;
}
}
async fetchPrevouts(transaction: Transaction): Promise<void> {
const prevoutsToFetch = transaction.vin.filter(input => !input.prevout).map((input) => ({ txid: input.txid, vout: input.vout }));
if (!prevoutsToFetch.length || transaction.vin[0].is_coinbase || this.offlineMode) {
this.hasPrevouts = !prevoutsToFetch.length || transaction.vin[0].is_coinbase;
this.fetchCpfp = this.hasPrevouts && !this.offlineMode;
} else {
try {
this.missingPrevouts = [];
this.isLoadingPrevouts = true;
const prevouts: { prevout: Vout, unconfirmed: boolean }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch));
if (prevouts?.length !== prevoutsToFetch.length) {
throw new Error();
}
let fetchIndex = 0;
transaction.vin.forEach(input => {
if (!input.prevout) {
const fetched = prevouts[fetchIndex];
if (fetched) {
input.prevout = fetched.prevout;
} else {
this.missingPrevouts.push(`${input.txid}:${input.vout}`);
}
fetchIndex++;
}
});
if (this.missingPrevouts.length) {
throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`);
}
this.hasPrevouts = true;
this.isLoadingPrevouts = false;
this.fetchCpfp = prevouts.some(prevout => prevout?.unconfirmed);
} catch (error) {
console.log(error);
this.errorPrevouts = error?.error?.error || error?.message;
this.isLoadingPrevouts = false;
}
}
if (this.hasPrevouts) {
transaction.fee = transaction.vin.some(input => input.is_coinbase)
? 0
: transaction.vin.reduce((fee, input) => {
return fee + (input.prevout?.value || 0);
}, 0) - transaction.vout.reduce((sum, output) => sum + output.value, 0);
transaction.feePerVsize = transaction.fee / (transaction.weight / 4);
}
transaction.vin.forEach(addInnerScriptsToVin);
transaction.sigops = countSigops(transaction);
}
async fetchCpfpInfo(transaction: Transaction): Promise<void> {
// Fetch potential cpfp data if all prevouts were parsed successfully and at least one of them is unconfirmed
if (this.hasPrevouts && this.fetchCpfp) {
try {
this.isLoadingCpfpInfo = true;
const cpfpInfo: CpfpInfo[] = await firstValueFrom(this.apiService.getCpfpLocalTx$([{
txid: transaction.txid,
weight: transaction.weight,
sigops: transaction.sigops,
fee: transaction.fee,
vin: transaction.vin,
vout: transaction.vout
}]));
if (cpfpInfo?.[0]?.ancestors?.length) {
const { ancestors, effectiveFeePerVsize } = cpfpInfo[0];
transaction.effectiveFeePerVsize = effectiveFeePerVsize;
this.cpfpInfo = { ancestors, effectiveFeePerVsize };
this.hasCpfp = true;
this.hasEffectiveFeeRate = true;
}
this.isLoadingCpfpInfo = false;
} catch (error) {
this.errorCpfpInfo = error?.error?.error || error?.message;
this.isLoadingCpfpInfo = false;
}
}
}
processTransaction(tx: Transaction, hex: string): void {
this.transaction = tx;
this.rawHexTransaction = hex;
this.transaction.flags = getTransactionFlags(this.transaction, this.cpfpInfo, null, null, this.stateService.network);
this.filters = this.transaction.flags ? toFilters(this.transaction.flags).filter(f => f.txPage) : [];
if (this.transaction.sigops >= 0) {
this.adjustedVsize = Math.max(this.transaction.weight / 4, this.transaction.sigops * 5);
}
this.setupGraph();
this.setFlowEnabled();
this.flowPrefSubscription = this.stateService.hideFlow.subscribe((hide) => {
this.hideFlow = !!hide;
this.setFlowEnabled();
});
this.setGraphSize();
this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe(() => {
if (this.transaction) {
this.stateService.markBlock$.next({
txid: this.transaction.txid,
txFeePerVSize: this.transaction.effectiveFeePerVsize || this.transaction.feePerVsize,
});
}
});
}
postTx(): void {
this.isLoadingBroadcast = true;
this.errorBroadcast = null;
this.broadcastSubscription = this.apiService.postTransaction$(this.rawHexTransaction).pipe(
tap((txid: string) => {
this.isLoadingBroadcast = false;
this.successBroadcast = true;
this.transaction.txid = txid;
}),
switchMap((txid: string) =>
timer(2000).pipe(
tap(() => this.router.navigate([this.relativeUrlPipe.transform('/tx/' + txid)])),
)
),
catchError((error) => {
if (typeof error.error === 'string') {
const matchText = error.error.replace(/\\/g, '').match('"message":"(.*?)"');
this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + (matchText && matchText[1] || error.error);
} else if (error.message) {
this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + error.message;
}
this.isLoadingBroadcast = false;
return throwError(() => error);
})
).subscribe();
}
resetState() {
this.transaction = null;
this.rawHexTransaction = null;
this.error = null;
this.errorPrevouts = null;
this.errorBroadcast = null;
this.successBroadcast = false;
this.isLoading = false;
this.isLoadingPrevouts = false;
this.isLoadingCpfpInfo = false;
this.isLoadingBroadcast = false;
this.adjustedVsize = null;
this.showCpfpDetails = false;
this.hasCpfp = false;
this.fetchCpfp = false;
this.cpfpInfo = null;
this.hasEffectiveFeeRate = false;
this.filters = [];
this.hasPrevouts = false;
this.missingPrevouts = [];
this.stateService.markBlock$.next({});
this.mempoolBlocksSubscription?.unsubscribe();
this.broadcastSubscription?.unsubscribe();
}
resetForm() {
this.resetState();
this.pushTxForm.get('txRaw').setValue('');
}
@HostListener('window:resize', ['$event'])
setGraphSize(): void {
this.isMobile = window.innerWidth < 850;
if (this.graphContainer?.nativeElement && this.stateService.isBrowser) {
setTimeout(() => {
if (this.graphContainer?.nativeElement?.clientWidth) {
this.graphWidth = this.graphContainer.nativeElement.clientWidth;
} else {
setTimeout(() => { this.setGraphSize(); }, 1);
}
}, 1);
} else {
setTimeout(() => { this.setGraphSize(); }, 1);
}
}
setupGraph() {
this.maxInOut = Math.min(this.inOutLimit, Math.max(this.transaction?.vin?.length || 1, this.transaction?.vout?.length + 1 || 1));
this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80);
}
toggleGraph() {
const showFlow = !this.flowEnabled;
this.stateService.hideFlow.next(!showFlow);
}
setFlowEnabled() {
this.flowEnabled = !this.hideFlow;
}
expandGraph() {
this.graphExpanded = true;
this.graphHeight = this.maxInOut * 15;
}
collapseGraph() {
this.graphExpanded = false;
this.graphHeight = Math.min(360, this.maxInOut * 80);
}
onOfflineModeChange(e): void {
this.offlineMode = !e.target.checked;
}
ngOnDestroy(): void {
this.mempoolBlocksSubscription?.unsubscribe();
this.flowPrefSubscription?.unsubscribe();
this.stateService.markBlock$.next({});
this.broadcastSubscription?.unsubscribe();
}
}

View File

@ -67,64 +67,7 @@
<ng-template [ngIf]="!isLoadingTx && !error">
<!-- CPFP Details -->
<ng-template [ngIf]="showCpfpDetails">
<br>
<div class="title">
<h2 class="text-left" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
</div>
<div class="box cpfp-details">
<table class="table table-fixed table-borderless table-striped">
<thead>
<tr>
<th i18n="transactions-list.vout.scriptpubkey-type">Type</th>
<th class="txids" i18n="dashboard.latest-transactions.txid">TXID</th>
<th *only-vsize class="d-none d-lg-table-cell" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</th>
<th *only-weight class="d-none d-lg-table-cell" i18n="transaction.weight|Transaction Weight">Weight</th>
<th i18n="transaction.fee-rate|Transaction fee rate">Fee rate</th>
<th class="d-none d-lg-table-cell"></th>
</tr>
</thead>
<tbody>
<ng-template [ngIf]="cpfpInfo?.descendants?.length">
<tr *ngFor="let cpfpTx of cpfpInfo.descendants">
<td><span class="badge badge-primary" i18n="transaction.descendant|Descendant">Descendant</span></td>
<td>
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
</td>
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) > roundToOneDecimal(tx)" class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
</tr>
</ng-template>
<ng-template [ngIf]="cpfpInfo?.bestDescendant">
<tr>
<td><span class="badge badge-success" i18n="transaction.descendant|Descendant">Descendant</span></td>
<td class="txids">
<app-truncate [text]="cpfpInfo.bestDescendant.txid" [link]="['/tx' | relativeUrl, cpfpInfo.bestDescendant.txid]"></app-truncate>
</td>
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight / 4 | vbytes: 2"></td>
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight | wuBytes: 2"></td>
<td><app-fee-rate [fee]="cpfpInfo.bestDescendant.fee" [weight]="cpfpInfo.bestDescendant.weight"></app-fee-rate></td>
<td class="d-none d-lg-table-cell"><fa-icon class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
</tr>
</ng-template>
<ng-template [ngIf]="cpfpInfo?.ancestors?.length">
<tr *ngFor="let cpfpTx of cpfpInfo.ancestors">
<td><span class="badge badge-primary" i18n="transaction.ancestor|Transaction Ancestor">Ancestor</span></td>
<td class="txids">
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
</td>
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) < roundToOneDecimal(tx)" class="arrow-red" [icon]="['fas', 'angle-double-down']" [fixedWidth]="true"></fa-icon></td>
</tr>
</ng-template>
</tbody>
</table>
</div>
</ng-template>
<app-cpfp-info *ngIf="showCpfpDetails" [cpfpInfo]="cpfpInfo" [tx]="tx"></app-cpfp-info>
<!-- Accelerator -->
<ng-container *ngIf="!tx?.status?.confirmed && showAccelerationSummary && (ETA$ | async) as eta;">

View File

@ -227,18 +227,6 @@
}
}
.cpfp-details {
.txids {
width: 60%;
}
@media (max-width: 500px) {
.txids {
width: 40%;
}
}
}
.tx-list {
.alert-link {
display: block;

View File

@ -1049,10 +1049,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.stateService.markBlock$.next({});
}
roundToOneDecimal(cpfpTx: any): number {
return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1);
}
setupGraph() {
this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1));
this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80);

View File

@ -9,6 +9,8 @@ import { TransactionExtrasModule } from '@components/transaction/transaction-ext
import { GraphsModule } from '@app/graphs/graphs.module';
import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-checkout.component';
import { AccelerateFeeGraphComponent } from '@components/accelerate-checkout/accelerate-fee-graph.component';
import { TransactionRawComponent } from '@components/transaction/transaction-raw.component';
import { CpfpInfoComponent } from '@components/transaction/cpfp-info.component';
const routes: Routes = [
{
@ -16,6 +18,10 @@ const routes: Routes = [
redirectTo: '/',
pathMatch: 'full',
},
{
path: 'preview',
component: TransactionRawComponent,
},
{
path: ':id',
component: TransactionComponent,
@ -49,12 +55,15 @@ export class TransactionRoutingModule { }
TransactionDetailsComponent,
AccelerateCheckout,
AccelerateFeeGraphComponent,
TransactionRawComponent,
CpfpInfoComponent,
],
exports: [
TransactionComponent,
TransactionDetailsComponent,
AccelerateCheckout,
AccelerateFeeGraphComponent,
CpfpInfoComponent,
]
})
export class TransactionModule { }

View File

@ -16,6 +16,11 @@
<div class="header-bg box" [attr.data-cy]="'tx-' + i">
<div *ngIf="errorUnblinded" class="error-unblinded">{{ errorUnblinded }}</div>
@if (similarityMatches.get(tx.txid)?.size) {
<div class="alert alert-mempool" role="alert">
<span i18n="transaction.poison.warning">Warning! This transaction involves deceptively similar addresses. It may be an address poisoning attack.</span>
</div>
}
<div class="row">
<div class="col">
<table class="table table-fixed table-borderless smaller-text table-sm table-tx-vin">
@ -68,9 +73,11 @@
</ng-template>
</ng-template>
<ng-template #defaultAddress>
<a class="address" *ngIf="vin.prevout.scriptpubkey_address; else vinScriptPubkeyType" [routerLink]="['/address/' | relativeUrl, vin.prevout.scriptpubkey_address]" title="{{ vin.prevout.scriptpubkey_address }}">
<app-truncate [text]="vin.prevout.scriptpubkey_address" [lastChars]="8"></app-truncate>
</a>
<app-address-text
*ngIf="vin.prevout.scriptpubkey_address; else vinScriptPubkeyType"
[address]="vin.prevout.scriptpubkey_address"
[similarity]="similarityMatches.get(tx.txid)?.get(vin.prevout.scriptpubkey_address)"
></app-address-text>
<ng-template #vinScriptPubkeyType>
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
</ng-template>
@ -216,9 +223,11 @@
'highlight': this.addresses.length && ((vout.scriptpubkey_type !== 'p2pk' && addresses.includes(vout.scriptpubkey_address)) || this.addresses.includes(vout.scriptpubkey.slice(2, -2)))
}">
<td class="address-cell">
<a class="address" *ngIf="vout.scriptpubkey_address; else pubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}">
<app-truncate [text]="vout.scriptpubkey_address" [lastChars]="8"></app-truncate>
</a>
<app-address-text
*ngIf="vout.scriptpubkey_address; else pubkey_type"
[address]="vout.scriptpubkey_address"
[similarity]="similarityMatches.get(tx.txid)?.get(vout.scriptpubkey_address)"
></app-address-text>
<ng-template #pubkey_type>
<ng-container *ngIf="vout.scriptpubkey_type === 'p2pk'; else scriptpubkey_type">
P2PK <a class="address p2pk-address" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey.slice(2, -2)]" title="{{ vout.scriptpubkey.slice(2, -2) }}">

View File

@ -14,6 +14,7 @@ import { StorageService } from '@app/services/storage.service';
import { OrdApiService } from '@app/services/ord-api.service';
import { Inscription } from '@app/shared/ord/inscription.utils';
import { Etching, Runestone } from '@app/shared/ord/rune.utils';
import { ADDRESS_SIMILARITY_THRESHOLD, AddressMatch, AddressSimilarity, AddressType, AddressTypeInfo, checkedCompareAddressStrings, detectAddressType } from '@app/shared/address-utils';
@Component({
selector: 'app-transactions-list',
@ -37,6 +38,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
@Input() addresses: string[] = [];
@Input() rowLimit = 12;
@Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
@Input() txPreview = false;
@Output() loadMore = new EventEmitter();
@ -54,6 +56,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
showFullScript: { [vinIndex: number]: boolean } = {};
showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {};
showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {};
similarityMatches: Map<string, Map<string, { score: number, match: AddressMatch, group: number }>> = new Map();
constructor(
public stateService: StateService,
@ -81,7 +84,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.refreshOutspends$
.pipe(
switchMap((txIds) => {
if (!this.cached) {
if (!this.cached && !this.txPreview) {
// break list into batches of 50 (maximum supported by esplora)
const batches = [];
for (let i = 0; i < txIds.length; i += 50) {
@ -119,7 +122,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
),
this.refreshChannels$
.pipe(
filter(() => this.stateService.networkSupportsLightning()),
filter(() => this.stateService.networkSupportsLightning() && !this.txPreview),
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
catchError((error) => {
// handle 404
@ -143,6 +146,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.currency = currency;
this.refreshPrice();
});
this.updateAddressSimilarities();
}
refreshPrice(): void {
@ -182,12 +187,17 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
}
if (changes.transactions || changes.addresses) {
this.similarityMatches.clear();
this.updateAddressSimilarities();
if (!this.transactions || !this.transactions.length) {
return;
}
this.transactionsLength = this.transactions.length;
this.cacheService.setTxCache(this.transactions);
if (!this.txPreview) {
this.cacheService.setTxCache(this.transactions);
}
const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length;
this.transactions.forEach((tx) => {
@ -292,6 +302,56 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
}
updateAddressSimilarities(): void {
if (!this.transactions || !this.transactions.length) {
return;
}
for (const tx of this.transactions) {
if (this.similarityMatches.get(tx.txid)) {
continue;
}
const similarityGroups: Map<string, number> = new Map();
let lastGroup = 0;
// Check for address poisoning similarity matches
this.similarityMatches.set(tx.txid, new Map());
const comparableVouts = [
...tx.vout.slice(0, 20),
...this.addresses.map(addr => ({ scriptpubkey_address: addr, scriptpubkey_type: detectAddressType(addr, this.stateService.network) }))
].filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v.scriptpubkey_type));
const comparableVins = tx.vin.slice(0, 20).map(v => v.prevout).filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v?.scriptpubkey_type));
for (const vout of comparableVouts) {
const address = vout.scriptpubkey_address;
const addressType = vout.scriptpubkey_type;
if (this.similarityMatches.get(tx.txid)?.has(address)) {
continue;
}
for (const compareAddr of [
...comparableVouts.filter(v => v.scriptpubkey_type === addressType && v.scriptpubkey_address !== address),
...comparableVins.filter(v => v.scriptpubkey_type === addressType && v.scriptpubkey_address !== address)
]) {
const similarity = checkedCompareAddressStrings(address, compareAddr.scriptpubkey_address, addressType as AddressType, this.stateService.network);
if (similarity?.status === 'comparable' && similarity.score > ADDRESS_SIMILARITY_THRESHOLD) {
let group = similarityGroups.get(address) || lastGroup++;
similarityGroups.set(address, group);
const bestVout = this.similarityMatches.get(tx.txid)?.get(address);
if (!bestVout || bestVout.score < similarity.score) {
this.similarityMatches.get(tx.txid)?.set(address, { score: similarity.score, match: similarity.left, group });
}
// opportunistically update the entry for the compared address
const bestCompare = this.similarityMatches.get(tx.txid)?.get(compareAddr.scriptpubkey_address);
if (!bestCompare || bestCompare.score < similarity.score) {
group = similarityGroups.get(compareAddr.scriptpubkey_address) || lastGroup++;
similarityGroups.set(compareAddr.scriptpubkey_address, group);
this.similarityMatches.get(tx.txid)?.set(compareAddr.scriptpubkey_address, { score: similarity.score, match: similarity.right, group });
}
}
}
}
}
}
onScroll(): void {
this.loadMore.emit();
}
@ -351,7 +411,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
loadMoreInputs(tx: Transaction): void {
if (!tx['@vinLoaded']) {
if (!tx['@vinLoaded'] && !this.txPreview) {
this.electrsApiService.getTransaction$(tx.txid)
.subscribe((newTx) => {
tx['@vinLoaded'] = true;

View File

@ -34,29 +34,39 @@ export class TwitterWidgetComponent implements OnChanges {
}
setIframeSrc(): void {
if (this.handle) {
this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL,
`https://syndication.x.com/srv/timeline-profile/screen-name/${this.handle}?creatorScreenName=mempool`
+ '&dnt=true'
+ '&embedId=twitter-widget-0'
+ '&features=eyJ0ZndfdGltZWxpbmVfgbGlzdCI6eyJidWNrZXQiOltdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2ZvbGxvd2VyX2NvdW50X3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9iYWNrZW5kIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19yZWZzcmNfc2Vzc2lvbiI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfZm9zbnJfc29mdF9pbnRlcnZlbnRpb25zX2VuYWJsZWQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X21peGVkX21lZGlhXzE1ODk3Ijp7ImJ1Y2tldCI6InRyZWF0bWVudCIsInZlcnNpb24iOm51bGx9LCJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3Nob3dfYmlyZHdhdGNoX3Bpdm90c19lbmFibGVkIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19kdXBsaWNhdGVfc2NyaWJlc190b19zZXR0aW5ncyI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdXNlX3Byb2ZpbGVfaW1hZ2Vfc2hhcGVfZW5hYmxlZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdmlkZW9faGxzX2R5bmFtaWNfbWFuaWZlc3RzXzE1MDgyIjp7ImJ1Y2tldCI6InRydWVfYml0cmF0ZSIsInZlcnNpb24iOm51bGx9LCJ0ZndfbGVnYWN5X3RpbWVsaW5lX3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9mcm9udGVuZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9fQ%3D%3D'
+ '&frame=false'
+ '&hideBorder=true'
+ '&hideFooter=false'
+ '&hideHeader=true'
+ '&hideScrollBar=false'
+ `&lang=${this.lang}`
+ '&maxHeight=500px'
+ '&origin=https%3A%2F%2Fmempool.space%2F'
// + '&sessionId=88f6d661d0dcca99c43c0a590f6a3e61c89226a9'
+ '&showHeader=false'
+ '&showReplies=false'
+ '&siteScreenName=mempool'
+ '&theme=dark'
+ '&transparent=true'
+ '&widgetsVersion=2615f7e52b7e0%3A1702314776716'
));
if (!this.handle) {
return;
}
let url = `https://syndication.x.com/srv/timeline-profile/screen-name/${this.handle}?creatorScreenName=mempool`
+ '&dnt=true'
+ '&embedId=twitter-widget-0'
+ '&features=eyJ0ZndfdGltZWxpbmVfgbGlzdCI6eyJidWNrZXQiOltdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2ZvbGxvd2VyX2NvdW50X3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9iYWNrZW5kIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19yZWZzcmNfc2Vzc2lvbiI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfZm9zbnJfc29mdF9pbnRlcnZlbnRpb25zX2VuYWJsZWQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X21peGVkX21lZGlhXzE1ODk3Ijp7ImJ1Y2tldCI6InRyZWF0bWVudCIsInZlcnNpb24iOm51bGx9LCJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3Nob3dfYmlyZHdhdGNoX3Bpdm90c19lbmFibGVkIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19kdXBsaWNhdGVfc2NyaWJlc190b19zZXR0aW5ncyI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdXNlX3Byb2ZpbGVfaW1hZ2Vfc2hhcGVfZW5hYmxlZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdmlkZW9faGxzX2R5bmFtaWNfbWFuaWZlc3RzXzE1MDgyIjp7ImJ1Y2tldCI6InRydWVfYml0cmF0ZSIsInZlcnNpb24iOm51bGx9LCJ0ZndfbGVnYWN5X3RpbWVsaW5lX3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9mcm9udGVuZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9fQ%3D%3D'
+ '&frame=false'
+ '&hideBorder=true'
+ '&hideFooter=false'
+ '&hideHeader=true'
+ '&hideScrollBar=false'
+ `&lang=${this.lang}`
+ '&maxHeight=500px'
+ '&origin=https%3A%2F%2Fmempool.space%2F'
// + '&sessionId=88f6d661d0dcca99c43c0a590f6a3e61c89226a9'
+ '&showHeader=false'
+ '&showReplies=false'
+ '&siteScreenName=mempool'
+ '&theme=dark'
+ '&transparent=true'
+ '&widgetsVersion=2615f7e52b7e0%3A1702314776716';
switch (this.handle.toLowerCase()) {
case 'nayibbukele':
url = 'https://bitcoin.gob.sv/twidget';
break;
case 'metaplanet_jp':
url = 'https://metaplanet.mempool.space/twidget';
break;
default:
break;
}
this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL, url));
}
onReady(): void {

Some files were not shown because too many files have changed in this diff Show More