Merge branch 'master' into simon/deprecating-tv-view

This commit is contained in:
wiz 2025-03-29 17:40:15 +09:00 committed by GitHub
commit 56fd407cdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 2910 additions and 228 deletions

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 }}"

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 }}/

View File

@ -12,12 +12,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",
"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

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

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

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

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

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

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

@ -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 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 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /backend

View File

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

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

@ -3,10 +3,10 @@ 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';
const hostname = process.env.CYPRESS_REROUTE_TESTNET === 'true' ? 'mempool-staging.fra.mempool.space' : 'node201.va1.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");
entry.target = entry.target.replace("liquid.network", "liquid-staging.va1.mempool.space");
});
module.exports = PROXY_CONFIG;

View File

@ -12,6 +12,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">

View File

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

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

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

@ -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>
@ -217,9 +224,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

@ -11803,7 +11803,7 @@ export const restApiDocsData = [
fragment: "accelerator-cancel",
title: "POST Cancel Acceleration (Pro)",
description: {
default: "<p>Sends a request to cancel an acceleration in the <code>accelerating</code> status.<br>You can retreive eligible acceleration <code>id</code> using the history endpoint GET <code>/api/v1/services/accelerator/history?status=accelerating</code>."
default: "<p>Sends a request to cancel an acceleration in the <code>accelerating</code> status.<br>You can retrieve eligible acceleration <code>id</code> using the history endpoint GET <code>/api/v1/services/accelerator/history?status=accelerating</code>."
},
urlString: "/v1/services/accelerator/cancel",
showConditions: [""],

View File

@ -1,7 +1,7 @@
// Import tree-shakeable echarts
import * as echarts from 'echarts/core';
import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, CustomChart } from 'echarts/charts';
import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent } from 'echarts/components';
import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent, GraphicComponent } from 'echarts/components';
import { SVGRenderer, CanvasRenderer } from 'echarts/renderers';
// Typescript interfaces
import { EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption } from 'echarts';
@ -13,6 +13,6 @@ echarts.use([
LegendComponent, GeoComponent, DataZoomComponent,
VisualMapComponent, MarkLineComponent,
LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart,
CustomChart,
CustomChart, GraphicComponent
]);
export { echarts, EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption };

View File

@ -14,7 +14,7 @@ class GuardService {
trackerGuard(route: Route, segments: UrlSegment[]): boolean {
const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode;
const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments;
return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test'].includes(path[1].path));
return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path));
}
}

View File

@ -565,6 +565,14 @@ export class ApiService {
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, '');
}
getPrevouts$(outpoints: {txid: string; vout: number}[]): Observable<any> {
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints);
}
getCpfpLocalTx$(tx: any[]): Observable<CpfpInfo[]> {
return this.httpClient.post<CpfpInfo[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/cpfp', tx);
}
// Cache methods
async setBlockAuditLoaded(hash: string) {
this.blockAuditLoaded[hash] = true;

View File

@ -213,7 +213,7 @@ export class ServicesApiServices {
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/payments/bitcoin`, params);
}
retreiveInvoice$(invoiceId: string): Observable<any[]> {
retrieveInvoice$(invoiceId: string): Observable<any[]> {
return this.httpClient.get<any[]>(`${this.stateService.env.SERVICES_API}/payments/bitcoin/invoice?id=${invoiceId}`);
}

View File

@ -78,6 +78,7 @@ const p2trRegex = RegExp('^p' + BECH32_CHARS_LW + '{58}$');
const pubkeyRegex = RegExp('^' + `(04${HEX_CHARS}{128})|(0[23]${HEX_CHARS}{64})$`);
export function detectAddressType(address: string, network: string): AddressType {
network = network || 'mainnet';
// normal address types
const firstChar = address.substring(0, 1);
if (ADDRESS_PREFIXES[network].base58.pubkey.includes(firstChar) && base58Regex.test(address.slice(1))) {
@ -211,6 +212,18 @@ export class AddressTypeInfo {
}
}
public compareTo(other: AddressTypeInfo): AddressSimilarityResult {
return compareAddresses(this.address, other.address, this.network);
}
public compareToString(other: string): AddressSimilarityResult {
if (other === this.address) {
return { status: 'identical' };
}
const otherInfo = new AddressTypeInfo(this.network, other);
return this.compareTo(otherInfo);
}
private processScript(script: ScriptInfo): void {
this.scripts.set(script.key, script);
if (script.template?.type === 'multisig') {
@ -218,3 +231,135 @@ export class AddressTypeInfo {
}
}
}
export interface AddressMatch {
prefix: string;
postfix: string;
}
export interface AddressSimilarity {
status: 'comparable';
score: number;
left: AddressMatch;
right: AddressMatch;
}
export type AddressSimilarityResult =
| { status: 'identical' }
| { status: 'incomparable' }
| AddressSimilarity;
export const ADDRESS_SIMILARITY_THRESHOLD = 10_000_000; // 1 false positive per ~10 million comparisons
function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score: number, matchA: string, matchB: string } {
let score = 0;
let gap = false;
let done = false;
let ai = 0;
let bi = 0;
let prefixA = '';
let prefixB = '';
if (rtl) {
a = a.split('').reverse().join('');
b = b.split('').reverse().join('');
}
while (ai < a.length && bi < b.length && !done) {
if (a[ai] === b[bi]) {
// matching characters
prefixA += a[ai];
prefixB += b[bi];
score++;
ai++;
bi++;
} else if (!gap) {
// try looking ahead in both strings to find the best match
const nextMatchA = (ai + 1 < a.length && a[ai + 1] === b[bi]);
const nextMatchB = (bi + 1 < b.length && a[ai] === b[bi + 1]);
const nextMatchBoth = (ai + 1 < a.length && bi + 1 < b.length && a[ai + 1] === b[bi + 1]);
if (nextMatchBoth) {
// single differing character
prefixA += a[ai];
prefixB += b[bi];
ai++;
bi++;
} else if (nextMatchA) {
// character missing in b
prefixA += a[ai];
ai++;
} else if (nextMatchB) {
// character missing in a
prefixB += b[bi];
bi++;
} else {
ai++;
bi++;
}
gap = true;
} else {
done = true;
}
}
if (rtl) {
prefixA = prefixA.split('').reverse().join('');
prefixB = prefixB.split('').reverse().join('');
}
return { score, matchA: prefixA, matchB: prefixB };
}
export function compareAddressInfo(a: AddressTypeInfo, b: AddressTypeInfo): AddressSimilarityResult {
if (a.address === b.address) {
return { status: 'identical' };
}
if (a.type !== b.type) {
return { status: 'incomparable' };
}
if (!['p2pkh', 'p2sh', 'p2sh-p2wpkh', 'p2sh-p2wsh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(a.type)) {
return { status: 'incomparable' };
}
const isBase58 = a.type === 'p2pkh' || a.type === 'p2sh';
const left = fuzzyPrefixMatch(a.address, b.address);
const right = fuzzyPrefixMatch(a.address, b.address, true);
// depending on address type, some number of matching prefix characters are guaranteed
const prefixScore = isBase58 ? 1 : ADDRESS_PREFIXES[a.network || 'mainnet'].bech32.length;
// add the two scores together
const totalScore = left.score + right.score - prefixScore;
// adjust for the size of the alphabet (58 vs 32)
const normalizedScore = Math.pow(isBase58 ? 58 : 32, totalScore);
return {
status: 'comparable',
score: normalizedScore,
left: {
prefix: left.matchA,
postfix: right.matchA,
},
right: {
prefix: left.matchB,
postfix: right.matchB,
},
};
}
export function compareAddresses(a: string, b: string, network: string): AddressSimilarityResult {
if (a === b) {
return { status: 'identical' };
}
const aInfo = new AddressTypeInfo(network, a);
return aInfo.compareToString(b);
}
// avoids the overhead of creating AddressTypeInfo objects for each address,
// but a and b *MUST* be valid normalized addresses, of the same valid type
export function checkedCompareAddressStrings(a: string, b: string, type: AddressType, network: string): AddressSimilarityResult {
return compareAddressInfo(
{ address: a, type: type, network: network } as AddressTypeInfo,
{ address: b, type: type, network: network } as AddressTypeInfo,
);
}

View File

@ -0,0 +1,17 @@
@if (similarity) {
<div class="address-text">
<a class="address" style="display: contents;" [routerLink]="['/address/' | relativeUrl, address]" title="{{ address }}">
<span class="prefix">{{ similarity.match.prefix }}</span>
<span class="infix" [ngStyle]="{'text-decoration-color': groupColors[similarity.group % (groupColors.length)]}">{{ address.slice(similarity.match.prefix.length || 0, -similarity.match.postfix.length || undefined) }}</span>
<span class="postfix"> {{ similarity.match.postfix }}</span>
</a>
<span class="poison-alert" *ngIf="similarity" i18n-ngbTooltip="address-poisoning.warning-tooltip" ngbTooltip="This address is deceptively similar to another output. It may be part of an address poisoning attack.">
<fa-icon [icon]="['fas', 'exclamation-triangle']" [fixedWidth]="true"></fa-icon>
</span>
</div>
} @else {
<a class="address" [routerLink]="['/address/' | relativeUrl, address]" title="{{ address }}">
<app-truncate [text]="address" [lastChars]="8"></app-truncate>
</a>
}

View File

@ -0,0 +1,32 @@
.address-text {
text-overflow: unset;
display: flex;
flex-direction: row;
align-items: start;
position: relative;
font-family: monospace;
.infix {
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
text-decoration: underline 2px;
}
.prefix, .postfix {
flex-shrink: 0;
flex-grow: 0;
user-select: none;
text-decoration: underline var(--red) 2px;
}
}
.poison-alert {
margin-left: .5em;
color: var(--yellow);
}

View File

@ -0,0 +1,20 @@
import { Component, Input } from '@angular/core';
import { AddressMatch, AddressTypeInfo } from '@app/shared/address-utils';
@Component({
selector: 'app-address-text',
templateUrl: './address-text.component.html',
styleUrls: ['./address-text.component.scss']
})
export class AddressTextComponent {
@Input() address: string;
@Input() info: AddressTypeInfo | null;
@Input() similarity: { score: number, match: AddressMatch, group: number } | null;
groupColors: string[] = [
'var(--primary)',
'var(--success)',
'var(--info)',
'white',
];
}

View File

@ -76,6 +76,7 @@
<p><a [routerLink]="['/blocks' | relativeUrl]" i18n="dashboard.recent-blocks">Recent Blocks</a></p>
<p><a [routerLink]="['/tx/push' | relativeUrl]" i18n="shared.broadcast-transaction|Broadcast Transaction">Broadcast Transaction</a></p>
<p><a [routerLink]="['/tx/test' | relativeUrl]" i18n="shared.test-transaction|Test Transaction">Test Transaction</a></p>
<p><a [routerLink]="['/tx/preview' | relativeUrl]" i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</a></p>
<p *ngIf="officialMempoolSpace"><a [routerLink]="['/lightning/group/the-mempool-open-source-project' | relativeUrl]" i18n="footer.connect-to-our-nodes">Connect to our Nodes</a></p>
<p><a [routerLink]="['/docs/api' | relativeUrl]" i18n="footer.api-documentation">API Documentation</a></p>
</div>
@ -85,6 +86,7 @@
<p><a [routerLink]="['/docs/faq']" fragment="what-is-a-block-explorer" i18n="faq.what-is-a-block-exlorer">What is a block explorer?</a></p>
<p><a [routerLink]="['/docs/faq']" fragment="what-is-a-mempool-explorer" i18n="faq.what-is-a-mempool-exlorer">What is a mempool explorer?</a></p>
<p><a [routerLink]="['/docs/faq']" fragment="why-is-transaction-stuck-in-mempool" i18n="faq.why-isnt-my-transaction-confirming">Why isn't my transaction confirming?</a></p>
<p><a [routerLink]="['/docs/faq']" fragment="host-my-own-instance-raspberry-pi" i18n="faq.be-your-own-explorer">Be your own explorer&trade;</a></p>
<p><a [routerLink]="['/docs/faq' | relativeUrl]" i18n="faq.more-faq">More FAQs &raquo;</a></p>
<p *ngIf="mempoolSpaceBuild"><a [routerLink]="['/research' | relativeUrl]" i18n="mempool-research">Research</a></p>
</div>

View File

@ -1,6 +1,6 @@
<span class="truncate" [style.max-width]="maxWidth ? maxWidth + 'px' : null" [style.justify-content]="textAlign" [class.inline]="inline">
<ng-container *ngIf="link">
<a [routerLink]="link" [queryParams]="queryParams" class="truncate-link" [target]="external ? '_blank' : '_self'">
<a [routerLink]="link" [queryParams]="queryParams" class="truncate-link" [target]="external ? '_blank' : '_self'" [class.disabled]="disabled">
<ng-container *ngIf="rtl; then rtlTruncated; else ltrTruncated;"></ng-container>
</a>
</ng-container>

View File

@ -37,6 +37,12 @@
max-width: 300px;
overflow: hidden;
}
.disabled {
pointer-events: none;
opacity: 0.8;
color: #fff;
}
}
@media (max-width: 567px) {

View File

@ -15,6 +15,7 @@ export class TruncateComponent {
@Input() maxWidth: number = null;
@Input() inline: boolean = false;
@Input() textAlign: 'start' | 'end' = 'start';
@Input() disabled: boolean = false;
rtl: boolean;
constructor(

View File

@ -251,6 +251,16 @@ export function detectScriptTemplate(type: ScriptType, script_asm: string, witne
return ScriptTemplates.multisig(multisig.m, multisig.n);
}
const tapscriptMultisig = parseTapscriptMultisig(script_asm);
if (tapscriptMultisig) {
return ScriptTemplates.multisig(tapscriptMultisig.m, tapscriptMultisig.n);
}
const tapscriptUnanimousMultisig = parseTapscriptUnanimousMultisig(script_asm);
if (tapscriptUnanimousMultisig) {
return ScriptTemplates.multisig(tapscriptUnanimousMultisig, tapscriptUnanimousMultisig);
}
return;
}
@ -299,6 +309,115 @@ export function parseMultisigScript(script: string): undefined | { m: number, n:
return { m, n };
}
export function parseTapscriptMultisig(script: string): undefined | { m: number, n: number } {
if (!script) {
return;
}
const ops = script.split(' ');
// At minimum, 2 pubkey group (3 tokens) + m push + final opcode = 8 tokens
if (ops.length < 8) {
return;
}
const finalOp = ops.pop();
if (!['OP_NUMEQUAL', 'OP_NUMEQUALVERIFY', 'OP_GREATERTHANOREQUAL', 'OP_GREATERTHAN', 'OP_EQUAL', 'OP_EQUALVERIFY'].includes(finalOp)) {
return;
}
let m: number;
if (['OP_PUSHBYTES_1', 'OP_PUSHBYTES_2'].includes(ops[ops.length - 2])) {
const data = ops.pop();
ops.pop();
m = parseInt(data.match(/../g).reverse().join(''), 16);
} else if (ops[ops.length - 1].startsWith('OP_PUSHNUM_') || ops[ops.length - 1] === 'OP_0') {
m = parseInt(ops.pop().match(/[0-9]+/)?.[0], 10);
} else {
return;
}
if (finalOp === 'OP_GREATERTHAN') {
m += 1;
}
if (ops.length % 3 !== 0) {
return;
}
const n = ops.length / 3;
if (n < 1) {
return;
}
for (let i = 0; i < n; i++) {
const push = ops.shift();
const pubkey = ops.shift();
const sigOp = ops.shift();
if (push !== 'OP_PUSHBYTES_32') {
return;
}
if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) {
return;
}
if (sigOp !== (i === 0 ? 'OP_CHECKSIG' : 'OP_CHECKSIGADD')) {
return;
}
}
if (ops.length) {
return;
}
return { m, n };
}
export function parseTapscriptUnanimousMultisig(script: string): undefined | number {
if (!script) {
return;
}
const ops = script.split(' ');
// At minimum, 2 pubkey group (3 tokens) = 6 tokens
if (ops.length < 6) {
return;
}
if (ops.length % 3 !== 0) {
return;
}
const n = ops.length / 3;
for (let i = 0; i < n; i++) {
const pushOp = ops.shift();
const pubkey = ops.shift();
const sigOp = ops.shift();
if (pushOp !== 'OP_PUSHBYTES_32') {
return;
}
if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) {
return;
}
if (i < n - 1) {
if (sigOp !== 'OP_CHECKSIGVERIFY') {
return;
}
} else {
// Last opcode can be either CHECKSIG or CHECKSIGVERIFY
if (!(sigOp === 'OP_CHECKSIGVERIFY' || sigOp === 'OP_CHECKSIG')) {
return;
}
}
}
if (ops.length) {
return;
}
return n;
}
export function getVarIntLength(n: number): number {
if (n < 0xfd) {
return 1;

View File

@ -7,7 +7,7 @@ import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, fa
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft,
faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck,
faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faFaucetDrip, faTimeline,
faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope } from '@fortawesome/free-solid-svg-icons';
faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { MenuComponent } from '@components/menu/menu.component';
import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component';
@ -94,6 +94,7 @@ import { SatsComponent } from '@app/shared/components/sats/sats.component';
import { BtcComponent } from '@app/shared/components/btc/btc.component';
import { FeeRateComponent } from '@app/shared/components/fee-rate/fee-rate.component';
import { AddressTypeComponent } from '@app/shared/components/address-type/address-type.component';
import { AddressTextComponent } from '@app/shared/components/address-text/address-text.component';
import { TruncateComponent } from '@app/shared/components/truncate/truncate.component';
import { SearchResultsComponent } from '@components/search-form/search-results/search-results.component';
import { TimestampComponent } from '@app/shared/components/timestamp/timestamp.component';
@ -214,6 +215,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c
BtcComponent,
FeeRateComponent,
AddressTypeComponent,
AddressTextComponent,
TruncateComponent,
SearchResultsComponent,
TimestampComponent,
@ -360,6 +362,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c
BtcComponent,
FeeRateComponent,
AddressTypeComponent,
AddressTextComponent,
TruncateComponent,
SearchResultsComponent,
TimestampComponent,
@ -464,5 +467,6 @@ export class SharedModule {
library.addIcons(faShareNodes);
library.addIcons(faCreditCard);
library.addIcons(faMicroscope);
library.addIcons(faExclamationTriangle);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,16 @@
@reboot sleep 5 ; /usr/local/bin/bitcoind -testnet >/dev/null 2>&1
@reboot sleep 5 ; /usr/local/bin/bitcoind -testnet4 >/dev/null 2>&1
@reboot sleep 5 ; /usr/local/bin/bitcoind -signet >/dev/null 2>&1
@reboot sleep 10 ; screen -dmS mainnet /bitcoin/electrs/start mainnet
@reboot sleep 10 ; screen -dmS testnet /bitcoin/electrs/start testnet
@reboot sleep 10 ; screen -dmS testnet4 /bitcoin/electrs/start testnet4
@reboot sleep 10 ; screen -dmS signet /bitcoin/electrs/start signet
# start test network daemons on boot
@reboot sleep 10 ; /usr/local/bin/bitcoind -testnet >/dev/null 2>&1
@reboot sleep 20 ; /usr/local/bin/bitcoind -testnet4 >/dev/null 2>&1
@reboot sleep 30 ; /usr/local/bin/bitcoind -signet >/dev/null 2>&1
# start electrs on boot
@reboot sleep 40 ; screen -dmS mainnet /bitcoin/electrs/start mainnet
@reboot sleep 50 ; screen -dmS testnet /bitcoin/electrs/start testnet
@reboot sleep 60 ; screen -dmS testnet4 /bitcoin/electrs/start testnet4
@reboot sleep 70 ; screen -dmS signet /bitcoin/electrs/start signet
# daily update of popular-scripts
30 03 * * * $HOME/electrs/start testnet4 popular-scripts >/dev/null 2>&1
31 03 * * * $HOME/electrs/start testnet popular-scripts >/dev/null 2>&1
32 03 * * * $HOME/electrs/start signet popular-scripts >/dev/null 2>&1
33 03 * * * $HOME/electrs/start mainnet popular-scripts >/dev/null 2>&1

View File

@ -18,28 +18,40 @@ check_mempoolfoss_frontend_md5_hash() {
check_mempool_electrs_git_hash() {
echo -n $(curl -s -i https://node${1}.${2}.mempool.space/api/mempool|grep -i x-powered-by|cut -d ' ' -f3|cut -d '-' -f3|tr -d '\r'|tr -d '\n')
}
check_mempool_electrs_negative_balance() {
echo -n $(curl -s https://node${1}.${2}.mempool.space/api/address/35Ty15fzBPGQvKnXZMLYvr41Fq2FTdU54a|jq .chain_stats.spent_txo_sum|tr -d '\r'|tr -d '\n')
}
check_liquid_electrs_git_hash() {
echo -n $(curl -s -i --connect-to "::node${1}.${2}.mempool.space:443" https://liquid.network/api/mempool|grep -i x-powered-by|cut -d ' ' -f3|cut -d '-' -f3|tr -d '\r'|tr -d '\n')
}
for site in fmt va1 fra tk7;do
check_contributors_md5_hash() {
echo -n $(curl -s --connect-to "::node${1}.${2}.mempool.space:443" https://mempool.space/api/v1/contributors|md5|cut -c1-8)
}
for site in va1 fra tk7 sg1 hnl;do
echo "${site}"
for node in 201 202 203 204 205 206 207 208 209 210 211 212 213 214;do
[ "${site}" = "fmt" ] && [ "${node}" = 203 ] && continue
[ "${site}" = "sg1" ] && [ "${node}" -gt 204 ] && continue
[ "${site}" = "hnl" ] && [ "${node}" -gt 204 ] && continue
[ "${site}" = "fmt" ] && [ "${node}" -gt 206 ] && continue
[ "${site}" = "tk7" ] && [ "${node}" -gt 206 ] && continue
echo -n "node${node}.${site}: "
check_mempoolspace_frontend_git_hash $node $site
echo -n " "
check_mempoolfoss_frontend_git_hash $node $site
echo -n " "
check_mempoolfoss_backend_git_hash $node $site
echo -n " "
check_mempoolspace_frontend_md5_hash $node $site
echo -n " "
check_mempoolfoss_frontend_md5_hash $node $site
echo -n " "
# check_mempoolspace_frontend_git_hash $node $site
# echo -n " "
# check_mempoolfoss_frontend_git_hash $node $site
# echo -n " "
# check_mempoolfoss_backend_git_hash $node $site
# echo -n " "
# check_mempoolspace_frontend_md5_hash $node $site
# echo -n " "
# check_mempoolfoss_frontend_md5_hash $node $site
# echo -n " "
check_mempool_electrs_git_hash $node $site
echo -n " "
check_liquid_electrs_git_hash $node $site
# check_liquid_electrs_git_hash $node $site
# echo -n " "
# check_contributors_md5_hash $node $site
check_mempool_electrs_negative_balance $node $site
echo
done
done

View File

@ -8,3 +8,7 @@
# hourly asset update and electrs restart
6 * * * * cd $HOME/asset_registry_db && git pull --quiet origin master && cd $HOME/asset_registry_testnet_db && git pull --quiet origin master && killall electrs
# daily update of popular-scripts
32 03 * * * $HOME/electrs/start liquid popular-scripts >/dev/null 2>&1
33 03 * * * $HOME/electrs/start liquidtestnet popular-scripts >/dev/null 2>&1

View File

@ -357,7 +357,7 @@ BITCOIN_REPO_URL=https://github.com/bitcoin/bitcoin
BITCOIN_REPO_NAME=bitcoin
BITCOIN_REPO_BRANCH=master
#BITCOIN_LATEST_RELEASE=$(curl -s https://api.github.com/repos/bitcoin/bitcoin/releases/latest|grep tag_name|head -1|cut -d '"' -f4)
BITCOIN_LATEST_RELEASE=v28.0
BITCOIN_LATEST_RELEASE=v28.1
echo -n '.'
BISQ_REPO_URL=https://github.com/bisq-network/bisq
@ -378,7 +378,7 @@ ELEMENTS_REPO_URL=https://github.com/ElementsProject/elements
ELEMENTS_REPO_NAME=elements
ELEMENTS_REPO_BRANCH=master
#ELEMENTS_LATEST_RELEASE=$(curl -s https://api.github.com/repos/ElementsProject/elements/releases/latest|grep tag_name|head -1|cut -d '"' -f4)
ELEMENTS_LATEST_RELEASE=elements-22.1.1
ELEMENTS_LATEST_RELEASE=elements-23.2.6
echo -n '.'
BITCOIN_ELECTRS_REPO_URL=https://github.com/mempool/electrs
@ -562,7 +562,7 @@ zfsCreateFilesystems()
zfs create -o "mountpoint=${MINFEE_HOME}" "${ZPOOL}/minfee"
zfs create -o "mountpoint=${ELECTRS_HOME}" "${ZPOOL}/electrs"
zfs create -o "mountpoint=${MEMPOOL_HOME}" "${ZPOOL}/mempool"
zfs create -o "mountpoint=${MYSQL_HOME}" "${ZPOOL}/mysql"
zfs create -o "mountpoint=${MYSQL_HOME}" "${ZPOOL}/mysql" || true
zfs create -o "mountpoint=${BITCOIN_ELECTRS_HOME}" "${ZPOOL}/bitcoin/electrs"
@ -1314,7 +1314,7 @@ if [ "${BITCOIN_ELECTRS_INSTALL}" = ON ];then
case $OS in
FreeBSD)
echo "[*] Patching Bitcoin Electrs code for FreeBSD"
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_HOME}/.cargo/registry/src/index.crates.io-6f17d22bba15001f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_HOME}/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
#osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/new_index/\" && sed -i.bak -e s/Snappy/None/ db.rs && rm db.rs.bak"
#osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/bin/\" && sed -i.bak -e 's/from_secs(5)/from_secs(1)/' electrs.rs && rm electrs.rs.bak"
;;
@ -1364,7 +1364,7 @@ if [ "${ELEMENTS_ELECTRS_INSTALL}" = ON ];then
case $OS in
FreeBSD)
echo "[*] Patching Liquid Electrs code for FreeBSD"
osSudo "${ELEMENTS_USER}" sh -c "cd \"${ELEMENTS_HOME}/.cargo/registry/src/index.crates.io-6f17d22bba15001f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
osSudo "${ELEMENTS_USER}" sh -c "cd \"${ELEMENTS_HOME}/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
;;
Debian)
;;
@ -1378,6 +1378,8 @@ fi
# Core Lightning for Bitcoin #
##############################
if [ "${CLN_INSTALL}" = ON ];then
echo "[*] Installing Core Lightning"
case $OS in
FreeBSD)
@ -1418,6 +1420,7 @@ case $OS in
;;
esac
fi
#####################
# Bisq installation #
@ -1907,34 +1910,34 @@ esac
sleep 10
mysql << _EOF_
create database mempool;
create database if not exists mempool;
grant all on mempool.* to '${MEMPOOL_MAINNET_USER}'@'localhost' identified by '${MEMPOOL_MAINNET_PASS}';
create database mempool_testnet;
create database if not exists mempool_testnet;
grant all on mempool_testnet.* to '${MEMPOOL_TESTNET_USER}'@'localhost' identified by '${MEMPOOL_TESTNET_PASS}';
create database mempool_testnet4;
create database if not exists mempool_testnet4;
grant all on mempool_testnet4.* to '${MEMPOOL_TESTNET4_USER}'@'localhost' identified by '${MEMPOOL_TESTNET4_PASS}';
create database mempool_signet;
create database if not exists mempool_signet;
grant all on mempool_signet.* to '${MEMPOOL_SIGNET_USER}'@'localhost' identified by '${MEMPOOL_SIGNET_PASS}';
create database mempool_mainnet_lightning;
create database if not exists mempool_mainnet_lightning;
grant all on mempool_mainnet_lightning.* to '${MEMPOOL_MAINNET_LIGHTNING_USER}'@'localhost' identified by '${MEMPOOL_MAINNET_LIGHTNING_PASS}';
create database mempool_testnet_lightning;
create database if not exists mempool_testnet_lightning;
grant all on mempool_testnet_lightning.* to '${MEMPOOL_TESTNET_LIGHTNING_USER}'@'localhost' identified by '${MEMPOOL_TESTNET_LIGHTNING_PASS}';
create database mempool_signet_lightning;
create database if not exists mempool_signet_lightning;
grant all on mempool_signet_lightning.* to '${MEMPOOL_SIGNET_LIGHTNING_USER}'@'localhost' identified by '${MEMPOOL_SIGNET_LIGHTNING_PASS}';
create database mempool_liquid;
create database if not exists mempool_liquid;
grant all on mempool_liquid.* to '${MEMPOOL_LIQUID_USER}'@'localhost' identified by '${MEMPOOL_LIQUID_PASS}';
create database mempool_liquidtestnet;
create database if not exists mempool_liquidtestnet;
grant all on mempool_liquidtestnet.* to '${MEMPOOL_LIQUIDTESTNET_USER}'@'localhost' identified by '${MEMPOOL_LIQUIDTESTNET_PASS}';
create database mempool_bisq;
create database if not exists mempool_bisq;
grant all on mempool_bisq.* to '${MEMPOOL_BISQ_USER}'@'localhost' identified by '${MEMPOOL_BISQ_PASS}';
_EOF_

View File

@ -173,7 +173,7 @@ for repo in $frontend_repos;do
done
# notify everyone
echo "${HOSTNAME} updated to \`${REF}\` @ \`${HASH}\`" | /usr/local/bin/keybase chat send --nonblock --channel general mempool.dev
echo "${HOSTNAME} updated to \`${REF}\` @ \`${HASH}\`" | /usr/local/bin/keybase chat send --nonblock --channel general "mempool.ops.${LOCATION}"
#echo "${HOSTNAME} updated to \`${REF}\` @ \`${HASH}\`" | /usr/local/bin/keybase chat send --nonblock --channel general mempool.dev
#echo "${HOSTNAME} updated to \`${REF}\` @ \`${HASH}\`" | /usr/local/bin/keybase chat send --nonblock --channel general "mempool.ops.${LOCATION}"
exit 0

View File

@ -159,6 +159,7 @@
},
"WALLETS": {
"ENABLED": true,
"AUTO": true,
"WALLETS": ["BITB", "3350"]
},
"STRATUM": {

View File

@ -4,7 +4,7 @@ hostname=$(hostname)
heat()
{
echo "$1"
curl -i -s "$1" | head -1
curl -o /dev/null -s "$1"
}
heatURLs=(

View File

@ -6,19 +6,19 @@ slugs=(`curl -sSL https://${hostname}/api/v1/mining/pools/3y|jq -r -S '(.pools[]
warmSlurp()
{
echo "$1"
curl -i -s -H 'User-Agent: Googlebot' "$1" | head -1
curl -o /dev/null -s -H 'User-Agent: Googlebot' "$1"
}
warmUnfurl()
{
echo "$1"
curl -i -s -H 'User-Agent: Twitterbot' "$1" | head -1
curl -o /dev/null -s -H 'User-Agent: Twitterbot' "$1"
}
warm()
{
echo "$1"
curl -i -s "$1" | head -1
curl -o /dev/null -s "$1"
}
warmSlurpURLs=(

View File

@ -99,8 +99,8 @@ http {
set $mempoolTestnet "http://mempool-liquid-testnet";
# for blockstream/esplora daemon, see upstream-esplora.conf
set $esploraMainnet "http://esplora-liquid-mainnet";
set $esploraTestnet "http://esplora-liquid-testnet";
set $esploraMainnet "http://esplora-elements-liquid";
set $esploraTestnet "http://esplora-elements-liquidtestnet";
# filesystem paths
root /mempool/public_html/liquid/;

View File

@ -9,7 +9,7 @@ server {
listen 127.0.0.1:4001;
access_log /dev/null;
location / {
proxy_pass http://esplora-liquid-mainnet;
proxy_pass http://esplora-elements-liquid;
}
}
server {
@ -30,6 +30,6 @@ server {
listen 127.0.0.1:4004;
access_log /dev/null;
location / {
proxy_pass http://esplora-liquid-testnet;
proxy_pass http://esplora-elements-liquidtestnet;
}
}

View File

@ -1,7 +1,7 @@
upstream esplora-bitcoin-mainnet {
server unix:/bitcoin/socket/esplora-bitcoin-mainnet fail_timeout=10s max_fails=10 weight=99999;
}
upstream esplora-liquid-mainnet {
upstream esplora-elements-liquid {
server unix:/elements/socket/esplora-elements-liquid fail_timeout=10s max_fails=10 weight=99999;
}
upstream esplora-bitcoin-testnet {
@ -13,6 +13,6 @@ upstream esplora-bitcoin-testnet4 {
upstream esplora-bitcoin-signet {
server unix:/bitcoin/socket/esplora-bitcoin-signet fail_timeout=10s max_fails=10 weight=99999;
}
upstream esplora-liquid-testnet {
upstream esplora-elements-liquidtestnet {
server unix:/elements/socket/esplora-elements-liquidtestnet fail_timeout=10s max_fails=10 weight=99999;
}