diff --git a/.github/workflows/docker_update_latest_tag.yml b/.github/workflows/docker_update_latest_tag.yml
new file mode 100644
index 000000000..5d21697d5
--- /dev/null
+++ b/.github/workflows/docker_update_latest_tag.yml
@@ -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 }}"
diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml
index 634a27ab9..ba9e1eb7b 100644
--- a/.github/workflows/on-tag.yml
+++ b/.github/workflows/on-tag.yml
@@ -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,20 @@ 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 }}/
\ No newline at end of file
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 3f66fa25b..a4963d6f0 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -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",
diff --git a/backend/package.json b/backend/package.json
index ee5944f93..bcbc0f256 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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",
diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts
index b56ca4861..73a14ba4e 100644
--- a/backend/src/api/bitcoin/bitcoin.routes.ts
+++ b/backend/src/api/bitcoin/bitcoin.routes.ts
@@ -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
@@ -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();
diff --git a/backend/src/api/cpfp.ts b/backend/src/api/cpfp.ts
index 9da11328b..953664fcc 100644
--- a/backend/src/api/cpfp.ts
+++ b/backend/src/api/cpfp.ts
@@ -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 || [],
diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts
index 4f43bd9d2..299cd309b 100644
--- a/backend/src/api/database-migration.ts
+++ b/backend/src/api/database-migration.ts
@@ -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);
+ }
}
/**
diff --git a/backend/src/api/transaction-utils.ts b/backend/src/api/transaction-utils.ts
index 28fa72bba..519527d5c 100644
--- a/backend/src/api/transaction-utils.ts
+++ b/backend/src/api/transaction-utils.ts
@@ -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();
diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile
index 60d663f20..e56b07da3 100644
--- a/docker/backend/Dockerfile
+++ b/docker/backend/Dockerfile
@@ -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
diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile
index 8374ebe49..8d97c9dc6 100644
--- a/docker/frontend/Dockerfile
+++ b/docker/frontend/Dockerfile
@@ -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}
diff --git a/frontend/proxy.conf.staging.js b/frontend/proxy.conf.staging.js
index 260b222c0..0165bed96 100644
--- a/frontend/proxy.conf.staging.js
+++ b/frontend/proxy.conf.staging.js
@@ -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;
diff --git a/frontend/src/app/components/about/about.component.html b/frontend/src/app/components/about/about.component.html
index 433fe1abb..3bd8960f5 100644
--- a/frontend/src/app/components/about/about.component.html
+++ b/frontend/src/app/components/about/about.component.html
@@ -12,6 +12,7 @@
The Mempool Open Source Project ®
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.
+
Be your own explorer™
diff --git a/frontend/src/app/components/address/address-preview.component.ts b/frontend/src/app/components/address/address-preview.component.ts
index bcc328787..1106d6096 100644
--- a/frontend/src/app/components/address/address-preview.component.ts
+++ b/frontend/src/app/components/address/address-preview.component.ts
@@ -36,6 +36,8 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
sent = 0;
totalUnspent = 0;
+ ogSession: number;
+
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
@@ -58,7 +60,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
.pipe(
switchMap((params: ParamMap) => {
this.rawAddress = params.get('id') || '';
- this.openGraphService.waitFor('address-data-' + this.rawAddress);
+ this.ogSession = this.openGraphService.waitFor('address-data-' + this.rawAddress);
this.error = undefined;
this.isLoadingAddress = true;
this.loadedConfirmedTxCount = 0;
@@ -79,7 +81,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
this.isLoadingAddress = false;
this.error = err;
console.log(err);
- this.openGraphService.fail('address-data-' + this.rawAddress);
+ this.openGraphService.fail({ event: 'address-data-' + this.rawAddress, sessionId: this.ogSession });
return of(null);
})
);
@@ -97,7 +99,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
this.address = address;
this.updateChainStats();
this.isLoadingAddress = false;
- this.openGraphService.waitOver('address-data-' + this.rawAddress);
+ this.openGraphService.waitOver({ event: 'address-data-' + this.rawAddress, sessionId: this.ogSession });
})
)
.subscribe(() => {},
@@ -105,7 +107,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
console.log(error);
this.error = error;
this.isLoadingAddress = false;
- this.openGraphService.fail('address-data-' + this.rawAddress);
+ this.openGraphService.fail({ event: 'address-data-' + this.rawAddress, sessionId: this.ogSession });
}
);
}
diff --git a/frontend/src/app/components/block/block-preview.component.html b/frontend/src/app/components/block/block-preview.component.html
index 036ab8399..6ea8e3387 100644
--- a/frontend/src/app/components/block/block-preview.component.html
+++ b/frontend/src/app/components/block/block-preview.component.html
@@ -49,7 +49,7 @@
-
+
Miner
diff --git a/frontend/src/app/components/block/block-preview.component.ts b/frontend/src/app/components/block/block-preview.component.ts
index 42a47f3c4..f5b31e846 100644
--- a/frontend/src/app/components/block/block-preview.component.ts
+++ b/frontend/src/app/components/block/block-preview.component.ts
@@ -35,6 +35,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
overviewSubscription: Subscription;
networkChangedSubscription: Subscription;
+ ogSession: number;
+
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
constructor(
@@ -53,8 +55,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
const block$ = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
this.rawId = params.get('id') || '';
- this.openGraphService.waitFor('block-viz-' + this.rawId);
- this.openGraphService.waitFor('block-data-' + this.rawId);
+ this.ogSession = this.openGraphService.waitFor('block-viz-' + this.rawId);
+ this.ogSession = this.openGraphService.waitFor('block-data-' + this.rawId);
const blockHash: string = params.get('id') || '';
this.block = undefined;
@@ -86,8 +88,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
catchError((err) => {
this.error = err;
this.seoService.logSoft404();
- this.openGraphService.fail('block-data-' + this.rawId);
- this.openGraphService.fail('block-viz-' + this.rawId);
+ this.openGraphService.fail({ event: 'block-data-' + this.rawId, sessionId: this.ogSession });
+ this.openGraphService.fail({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession });
return of(null);
}),
);
@@ -114,7 +116,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
this.isLoadingOverview = true;
this.overviewError = null;
- this.openGraphService.waitOver('block-data-' + this.rawId);
+ this.openGraphService.waitOver({ event: 'block-data-' + this.rawId, sessionId: this.ogSession });
}),
throttleTime(50, asyncScheduler, { leading: true, trailing: true }),
shareReplay({ bufferSize: 1, refCount: true })
@@ -129,7 +131,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
.pipe(
catchError((err) => {
this.overviewError = err;
- this.openGraphService.fail('block-viz-' + this.rawId);
+ this.openGraphService.fail({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession });
return of([]);
}),
switchMap((transactions) => {
@@ -138,7 +140,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
),
this.stateService.env.ACCELERATOR === true && block.height > 819500
? this.servicesApiService.getAllAccelerationHistory$({ blockHeight: block.height })
- .pipe(catchError(() => {
+ .pipe(
+ catchError(() => {
return of([]);
}))
: of([])
@@ -169,8 +172,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
this.error = error;
this.isLoadingOverview = false;
this.seoService.logSoft404();
- this.openGraphService.fail('block-viz-' + this.rawId);
- this.openGraphService.fail('block-data-' + this.rawId);
+ this.openGraphService.fail({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession });
+ this.openGraphService.fail({ event: 'block-data-' + this.rawId, sessionId: this.ogSession });
if (this.blockGraph) {
this.blockGraph.destroy();
}
@@ -196,6 +199,6 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
}
onGraphReady(): void {
- this.openGraphService.waitOver('block-viz-' + this.rawId);
+ this.openGraphService.waitOver({ event: 'block-viz-' + this.rawId, sessionId: this.ogSession });
}
}
diff --git a/frontend/src/app/components/pool/pool-preview.component.ts b/frontend/src/app/components/pool/pool-preview.component.ts
index 93077120d..7478e5f6f 100644
--- a/frontend/src/app/components/pool/pool-preview.component.ts
+++ b/frontend/src/app/components/pool/pool-preview.component.ts
@@ -30,6 +30,8 @@ export class PoolPreviewComponent implements OnInit {
slug: string = undefined;
+ ogSession: number;
+
constructor(
@Inject(LOCALE_ID) public locale: string,
private apiService: ApiService,
@@ -47,22 +49,22 @@ export class PoolPreviewComponent implements OnInit {
this.isLoading = true;
this.imageLoaded = false;
this.slug = slug;
- this.openGraphService.waitFor('pool-hash-' + this.slug);
- this.openGraphService.waitFor('pool-stats-' + this.slug);
- this.openGraphService.waitFor('pool-chart-' + this.slug);
- this.openGraphService.waitFor('pool-img-' + this.slug);
+ this.ogSession = this.openGraphService.waitFor('pool-hash-' + this.slug);
+ this.ogSession = this.openGraphService.waitFor('pool-stats-' + this.slug);
+ this.ogSession = this.openGraphService.waitFor('pool-chart-' + this.slug);
+ this.ogSession = this.openGraphService.waitFor('pool-img-' + this.slug);
return this.apiService.getPoolHashrate$(this.slug)
.pipe(
switchMap((data) => {
this.isLoading = false;
this.prepareChartOptions(data.map(val => [val.timestamp * 1000, val.avgHashrate]));
- this.openGraphService.waitOver('pool-hash-' + this.slug);
+ this.openGraphService.waitOver({ event: 'pool-hash-' + this.slug, sessionId: this.ogSession });
return [slug];
}),
catchError(() => {
this.isLoading = false;
this.seoService.logSoft404();
- this.openGraphService.fail('pool-hash-' + this.slug);
+ this.openGraphService.fail({ event: 'pool-hash-' + this.slug, sessionId: this.ogSession });
return of([slug]);
})
);
@@ -72,7 +74,7 @@ export class PoolPreviewComponent implements OnInit {
catchError(() => {
this.isLoading = false;
this.seoService.logSoft404();
- this.openGraphService.fail('pool-stats-' + this.slug);
+ this.openGraphService.fail({ event: 'pool-stats-' + this.slug, sessionId: this.ogSession });
return of(null);
})
);
@@ -90,11 +92,11 @@ export class PoolPreviewComponent implements OnInit {
}
poolStats.pool.regexes = regexes.slice(0, -3);
- this.openGraphService.waitOver('pool-stats-' + this.slug);
+ this.openGraphService.waitOver({ event: 'pool-stats-' + this.slug, sessionId: this.ogSession });
const logoSrc = `/resources/mining-pools/` + poolStats.pool.slug + '.svg';
if (logoSrc === this.lastImgSrc) {
- this.openGraphService.waitOver('pool-img-' + this.slug);
+ this.openGraphService.waitOver({ event: 'pool-img-' + this.slug, sessionId: this.ogSession });
}
this.lastImgSrc = logoSrc;
return Object.assign({
@@ -103,7 +105,7 @@ export class PoolPreviewComponent implements OnInit {
}),
catchError(() => {
this.isLoading = false;
- this.openGraphService.fail('pool-stats-' + this.slug);
+ this.openGraphService.fail({ event: 'pool-stats-' + this.slug, sessionId: this.ogSession });
return of(null);
})
);
@@ -170,16 +172,16 @@ export class PoolPreviewComponent implements OnInit {
}
onChartReady(): void {
- this.openGraphService.waitOver('pool-chart-' + this.slug);
+ this.openGraphService.waitOver({ event: 'pool-chart-' + this.slug, sessionId: this.ogSession });
}
onImageLoad(): void {
this.imageLoaded = true;
- this.openGraphService.waitOver('pool-img-' + this.slug);
+ this.openGraphService.waitOver({ event: 'pool-img-' + this.slug, sessionId: this.ogSession });
}
onImageFail(): void {
this.imageLoaded = false;
- this.openGraphService.waitOver('pool-img-' + this.slug);
+ this.openGraphService.waitOver({ event: 'pool-img-' + this.slug, sessionId: this.ogSession });
}
}
diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts
index 31317cab5..7db1a75e1 100644
--- a/frontend/src/app/components/start/start.component.ts
+++ b/frontend/src/app/components/start/start.component.ts
@@ -194,14 +194,16 @@ export class StartComponent implements OnInit, AfterViewChecked, OnDestroy {
applyScrollLeft(): void {
if (this.blockchainContainer?.nativeElement?.scrollWidth) {
let lastScrollLeft = null;
- while (this.scrollLeft < 0 && this.shiftPagesForward() && lastScrollLeft !== this.scrollLeft) {
- lastScrollLeft = this.scrollLeft;
- this.scrollLeft += this.pageWidth;
- }
- lastScrollLeft = null;
- while (this.scrollLeft > this.blockchainContainer.nativeElement.scrollWidth && this.shiftPagesBack() && lastScrollLeft !== this.scrollLeft) {
- lastScrollLeft = this.scrollLeft;
- this.scrollLeft -= this.pageWidth;
+ if (!this.timeLtr) {
+ while (this.scrollLeft < 0 && this.shiftPagesForward() && lastScrollLeft !== this.scrollLeft) {
+ lastScrollLeft = this.scrollLeft;
+ this.scrollLeft += this.pageWidth;
+ }
+ lastScrollLeft = null;
+ while (this.scrollLeft > this.blockchainContainer.nativeElement.scrollWidth && this.shiftPagesBack() && lastScrollLeft !== this.scrollLeft) {
+ lastScrollLeft = this.scrollLeft;
+ this.scrollLeft -= this.pageWidth;
+ }
}
this.blockchainContainer.nativeElement.scrollLeft = this.scrollLeft;
}
diff --git a/frontend/src/app/components/transaction/cpfp-info.component.html b/frontend/src/app/components/transaction/cpfp-info.component.html
new file mode 100644
index 000000000..55945c388
--- /dev/null
+++ b/frontend/src/app/components/transaction/cpfp-info.component.html
@@ -0,0 +1,56 @@
+
+
+
Related Transactions
+
+
+
+
+
+ Type
+ TXID
+ Virtual size
+ Weight
+ Fee rate
+
+
+
+
+
+
+ Descendant
+
+
+
+
+
+
+ roundToOneDecimal(tx)" class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true">
+
+
+
+
+ Descendant
+
+
+
+
+
+
+
+
+
+
+
+ Ancestor
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/components/transaction/cpfp-info.component.scss b/frontend/src/app/components/transaction/cpfp-info.component.scss
new file mode 100644
index 000000000..df2b622e7
--- /dev/null
+++ b/frontend/src/app/components/transaction/cpfp-info.component.scss
@@ -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;
+}
\ No newline at end of file
diff --git a/frontend/src/app/components/transaction/cpfp-info.component.ts b/frontend/src/app/components/transaction/cpfp-info.component.ts
new file mode 100644
index 000000000..3d122183b
--- /dev/null
+++ b/frontend/src/app/components/transaction/cpfp-info.component.ts
@@ -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);
+ }
+}
diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html
index c5609882c..78bba955c 100644
--- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html
+++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.html
@@ -153,7 +153,7 @@
@if (!isLoadingTx) {
- @if (!replaced && !isCached) {
+ @if (!replaced && !isCached && !unbroadcasted) {
ETA
@@ -184,7 +184,7 @@
}
- } @else {
+ } @else if (!unbroadcasted){
}
@@ -213,11 +213,11 @@
@if (!isLoadingTx) {
Fee
- {{ tx.fee | number }} sats
+ {{ (tx.fee | number) ?? '-' }} sats
@if (isAcceleration && accelerationInfo?.bidBoost ?? tx.feeDelta > 0) {
+{{ accelerationInfo?.bidBoost ?? tx.feeDelta | number }} sats
}
-
+ = 0" [blockConversion]="tx.price" [value]="tx.fee + (isAcceleration ? ((accelerationInfo?.bidBoost ?? tx.feeDelta) || 0) : 0)">
} @else {
diff --git a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts
index 2b539c154..c6260da48 100644
--- a/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts
+++ b/frontend/src/app/components/transaction/transaction-details/transaction-details.component.ts
@@ -38,6 +38,7 @@ export class TransactionDetailsComponent implements OnInit {
@Input() replaced: boolean;
@Input() isCached: boolean;
@Input() ETA$: Observable;
+ @Input() unbroadcasted: boolean;
@Output() accelerateClicked = new EventEmitter();
@Output() toggleCpfp$ = new EventEmitter();
diff --git a/frontend/src/app/components/transaction/transaction-preview.component.ts b/frontend/src/app/components/transaction/transaction-preview.component.ts
index 0c51e0064..4746f9de7 100644
--- a/frontend/src/app/components/transaction/transaction-preview.component.ts
+++ b/frontend/src/app/components/transaction/transaction-preview.component.ts
@@ -43,6 +43,8 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
opReturns: Vout[];
extraData: 'none' | 'coinbase' | 'opreturn';
+ ogSession: number;
+
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
@@ -75,7 +77,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
)
.subscribe((cpfpInfo) => {
this.cpfpInfo = cpfpInfo;
- this.openGraphService.waitOver('cpfp-data-' + this.txId);
+ this.openGraphService.waitOver({ event: 'cpfp-data-' + this.txId, sessionId: this.ogSession });
});
this.subscription = this.route.paramMap
@@ -83,8 +85,8 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
switchMap((params: ParamMap) => {
const urlMatch = (params.get('id') || '').split(':');
this.txId = urlMatch[0];
- this.openGraphService.waitFor('tx-data-' + this.txId);
- this.openGraphService.waitFor('tx-time-' + this.txId);
+ this.ogSession = this.openGraphService.waitFor('tx-data-' + this.txId);
+ this.ogSession = this.openGraphService.waitFor('tx-time-' + this.txId);
this.seoService.setTitle(
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
);
@@ -138,7 +140,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
.subscribe((tx: Transaction) => {
if (!tx) {
this.seoService.logSoft404();
- this.openGraphService.fail('tx-data-' + this.txId);
+ this.openGraphService.fail({ event: 'tx-data-' + this.txId, sessionId: this.ogSession });
return;
}
@@ -155,10 +157,10 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
if (tx.status.confirmed) {
this.transactionTime = tx.status.block_time;
- this.openGraphService.waitOver('tx-time-' + this.txId);
+ this.openGraphService.waitOver({ event: 'tx-time-' + this.txId, sessionId: this.ogSession });
} else if (!tx.status.confirmed && tx.firstSeen) {
this.transactionTime = tx.firstSeen;
- this.openGraphService.waitOver('tx-time-' + this.txId);
+ this.openGraphService.waitOver({ event: 'tx-time-' + this.txId, sessionId: this.ogSession });
} else {
this.getTransactionTime();
}
@@ -184,11 +186,11 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
}
}
- this.openGraphService.waitOver('tx-data-' + this.txId);
+ this.openGraphService.waitOver({ event: 'tx-data-' + this.txId, sessionId: this.ogSession });
},
(error) => {
this.seoService.logSoft404();
- this.openGraphService.fail('tx-data-' + this.txId);
+ this.openGraphService.fail({ event: 'tx-data-' + this.txId, sessionId: this.ogSession });
this.error = error;
this.isLoadingTx = false;
}
@@ -205,7 +207,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
)
.subscribe((transactionTimes) => {
this.transactionTime = transactionTimes[0];
- this.openGraphService.waitOver('tx-time-' + this.txId);
+ this.openGraphService.waitOver({ event: 'tx-time-' + this.txId, sessionId: this.ogSession });
});
}
diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html
new file mode 100644
index 000000000..3bd8ee6d2
--- /dev/null
+++ b/frontend/src/app/components/transaction/transaction-raw.component.html
@@ -0,0 +1,212 @@
+
+
+ @if (!transaction) {
+
+
Preview Transaction
+
+
+ }
+
+ @if (transaction && !error && !isLoading) {
+
+
Preview Transaction
+
+
+
+
+
+
+
+
+
+
+ Broadcasted
+ ✕
+
+
+
+
+
{{ errorBroadcast }}
+
+
+
+
+
+
+
+ This transaction is stored locally in your browser. Broadcast it to add it to the mempool.
+
+
+ Broadcast
+
+
+ @if (!hasPrevouts) {
+
+ @if (offlineMode) {
+ Missing prevouts are not loaded. Some fields like fee rate cannot be calculated.
+ } @else {
+ Error loading missing prevouts . {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }}
+ }
+
+ }
+
+ @if (errorCpfpInfo) {
+
+ Error loading CPFP data . Reason: {{ errorCpfpInfo }}
+
+ }
+
+
+
+
+
+
+
+
+
Flow
+
+
+ Hide diagram
+
+
+
+
+
+
+
+
+
24">
+ Show more
+
+ Show less
+
+
+
+
+
+
+
+
+
+
+
+
+
Inputs & Outputs
+
+
+
+ Show diagram
+ Details
+
+
+
+
+
+
+
+
Details
+
+
+
+
+
+
+
+ Size
+
+
+
+ Virtual size
+
+
+
+ Adjusted vsize
+
+
+
+
+
+
+
+ Weight
+
+
+
+
+
+
+
+
+
+ Version
+
+
+
+ Locktime
+
+
+ = 0">
+ Sigops
+
+
+
+
+
+
+
+ Transaction hex
+
+
+
+
+
+
+
+ }
+
+ @if (isLoading) {
+
+
+
+ Loading {{ isLoadingPrevouts ? 'transaction prevouts' : isLoadingCpfpInfo ? 'CPFP' : '' }}
+
+
+ }
+
\ No newline at end of file
diff --git a/frontend/src/app/components/transaction/transaction-raw.component.scss b/frontend/src/app/components/transaction/transaction-raw.component.scss
new file mode 100644
index 000000000..5bbe5601e
--- /dev/null
+++ b/frontend/src/app/components/transaction/transaction-raw.component.scss
@@ -0,0 +1,194 @@
+.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;
+}
\ No newline at end of file
diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts
new file mode 100644
index 000000000..5ce170e12
--- /dev/null
+++ b/frontend/src/app/components/transaction/transaction-raw.component.ts
@@ -0,0 +1,313 @@
+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 { firstValueFrom, Subscription } 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;
+
+ 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 {
+ this.resetState();
+ this.isLoading = true;
+ try {
+ const { tx, hex } = decodeRawTransaction(this.pushTxForm.get('txRaw').value, 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 {
+ 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 {
+ // 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,
+ });
+ }
+ });
+ }
+
+ async postTx(): Promise {
+ this.isLoadingBroadcast = true;
+ this.errorBroadcast = null;
+ return new Promise((resolve, reject) => {
+ this.apiService.postTransaction$(this.rawHexTransaction)
+ .subscribe((result) => {
+ this.isLoadingBroadcast = false;
+ this.successBroadcast = true;
+ this.transaction.txid = result;
+ resolve(result);
+ },
+ (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;
+ reject(this.error);
+ });
+ });
+ }
+
+ 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();
+ }
+
+ 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({});
+ }
+
+}
diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html
index d49ca7049..7fd8c2b52 100644
--- a/frontend/src/app/components/transaction/transaction.component.html
+++ b/frontend/src/app/components/transaction/transaction.component.html
@@ -67,64 +67,7 @@
-
-
-
-
Related Transactions
-
-
-
-
-
- Type
- TXID
- Virtual size
- Weight
- Fee rate
-
-
-
-
-
-
- Descendant
-
-
-
-
-
-
- roundToOneDecimal(tx)" class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true">
-
-
-
-
- Descendant
-
-
-
-
-
-
-
-
-
-
-
- Ancestor
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/frontend/src/app/components/transaction/transaction.component.scss b/frontend/src/app/components/transaction/transaction.component.scss
index d35f26130..fed9f742c 100644
--- a/frontend/src/app/components/transaction/transaction.component.scss
+++ b/frontend/src/app/components/transaction/transaction.component.scss
@@ -227,18 +227,6 @@
}
}
-.cpfp-details {
- .txids {
- width: 60%;
- }
-
- @media (max-width: 500px) {
- .txids {
- width: 40%;
- }
- }
-}
-
.tx-list {
.alert-link {
display: block;
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts
index 60427e124..b5d3752ae 100644
--- a/frontend/src/app/components/transaction/transaction.component.ts
+++ b/frontend/src/app/components/transaction/transaction.component.ts
@@ -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);
diff --git a/frontend/src/app/components/transaction/transaction.module.ts b/frontend/src/app/components/transaction/transaction.module.ts
index 80de0cf40..a05191346 100644
--- a/frontend/src/app/components/transaction/transaction.module.ts
+++ b/frontend/src/app/components/transaction/transaction.module.ts
@@ -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 { }
diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts
index 8e67ccdfc..20f11abd8 100644
--- a/frontend/src/app/components/transactions-list/transactions-list.component.ts
+++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts
@@ -37,6 +37,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();
@@ -81,7 +82,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 +120,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
@@ -187,7 +188,10 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
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) => {
@@ -351,7 +355,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;
diff --git a/frontend/src/app/components/twitter-widget/twitter-widget.component.ts b/frontend/src/app/components/twitter-widget/twitter-widget.component.ts
index 06b50b1dc..8f5894ad0 100644
--- a/frontend/src/app/components/twitter-widget/twitter-widget.component.ts
+++ b/frontend/src/app/components/twitter-widget/twitter-widget.component.ts
@@ -34,29 +34,39 @@ export class TwitterWidgetComponent implements OnChanges {
}
setIframeSrc(): void {
- if (this.handle) {
- this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL,
- `https://syndication.x.com/srv/timeline-profile/screen-name/${this.handle}?creatorScreenName=mempool`
- + '&dnt=true'
- + '&embedId=twitter-widget-0'
- + '&features=eyJ0ZndfdGltZWxpbmVfgbGlzdCI6eyJidWNrZXQiOltdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2ZvbGxvd2VyX2NvdW50X3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9iYWNrZW5kIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19yZWZzcmNfc2Vzc2lvbiI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfZm9zbnJfc29mdF9pbnRlcnZlbnRpb25zX2VuYWJsZWQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X21peGVkX21lZGlhXzE1ODk3Ijp7ImJ1Y2tldCI6InRyZWF0bWVudCIsInZlcnNpb24iOm51bGx9LCJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3Nob3dfYmlyZHdhdGNoX3Bpdm90c19lbmFibGVkIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19kdXBsaWNhdGVfc2NyaWJlc190b19zZXR0aW5ncyI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdXNlX3Byb2ZpbGVfaW1hZ2Vfc2hhcGVfZW5hYmxlZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdmlkZW9faGxzX2R5bmFtaWNfbWFuaWZlc3RzXzE1MDgyIjp7ImJ1Y2tldCI6InRydWVfYml0cmF0ZSIsInZlcnNpb24iOm51bGx9LCJ0ZndfbGVnYWN5X3RpbWVsaW5lX3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9mcm9udGVuZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9fQ%3D%3D'
- + '&frame=false'
- + '&hideBorder=true'
- + '&hideFooter=false'
- + '&hideHeader=true'
- + '&hideScrollBar=false'
- + `&lang=${this.lang}`
- + '&maxHeight=500px'
- + '&origin=https%3A%2F%2Fmempool.space%2F'
- // + '&sessionId=88f6d661d0dcca99c43c0a590f6a3e61c89226a9'
- + '&showHeader=false'
- + '&showReplies=false'
- + '&siteScreenName=mempool'
- + '&theme=dark'
- + '&transparent=true'
- + '&widgetsVersion=2615f7e52b7e0%3A1702314776716'
- ));
+ if (!this.handle) {
+ return;
}
+ let url = `https://syndication.x.com/srv/timeline-profile/screen-name/${this.handle}?creatorScreenName=mempool`
+ + '&dnt=true'
+ + '&embedId=twitter-widget-0'
+ + '&features=eyJ0ZndfdGltZWxpbmVfgbGlzdCI6eyJidWNrZXQiOltdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2ZvbGxvd2VyX2NvdW50X3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9iYWNrZW5kIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19yZWZzcmNfc2Vzc2lvbiI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfZm9zbnJfc29mdF9pbnRlcnZlbnRpb25zX2VuYWJsZWQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X21peGVkX21lZGlhXzE1ODk3Ijp7ImJ1Y2tldCI6InRyZWF0bWVudCIsInZlcnNpb24iOm51bGx9LCJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3Nob3dfYmlyZHdhdGNoX3Bpdm90c19lbmFibGVkIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19kdXBsaWNhdGVfc2NyaWJlc190b19zZXR0aW5ncyI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdXNlX3Byb2ZpbGVfaW1hZ2Vfc2hhcGVfZW5hYmxlZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdmlkZW9faGxzX2R5bmFtaWNfbWFuaWZlc3RzXzE1MDgyIjp7ImJ1Y2tldCI6InRydWVfYml0cmF0ZSIsInZlcnNpb24iOm51bGx9LCJ0ZndfbGVnYWN5X3RpbWVsaW5lX3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9mcm9udGVuZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9fQ%3D%3D'
+ + '&frame=false'
+ + '&hideBorder=true'
+ + '&hideFooter=false'
+ + '&hideHeader=true'
+ + '&hideScrollBar=false'
+ + `&lang=${this.lang}`
+ + '&maxHeight=500px'
+ + '&origin=https%3A%2F%2Fmempool.space%2F'
+ // + '&sessionId=88f6d661d0dcca99c43c0a590f6a3e61c89226a9'
+ + '&showHeader=false'
+ + '&showReplies=false'
+ + '&siteScreenName=mempool'
+ + '&theme=dark'
+ + '&transparent=true'
+ + '&widgetsVersion=2615f7e52b7e0%3A1702314776716';
+ switch (this.handle.toLowerCase()) {
+ case 'nayibbukele':
+ url = 'https://bitcoin.gob.sv/twidget';
+ break;
+ case 'metaplanet_jp':
+ url = 'https://metaplanet.mempool.space/twidget';
+ break;
+ default:
+ break;
+ }
+ this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL, url));
}
onReady(): void {
diff --git a/frontend/src/app/components/wallet/wallet-preview.component.ts b/frontend/src/app/components/wallet/wallet-preview.component.ts
index 0387822aa..bbf8ecf7a 100644
--- a/frontend/src/app/components/wallet/wallet-preview.component.ts
+++ b/frontend/src/app/components/wallet/wallet-preview.component.ts
@@ -125,6 +125,8 @@ export class WalletPreviewComponent implements OnInit, OnDestroy {
sent = 0;
chainBalance = 0;
+ ogSession: number;
+
constructor(
private route: ActivatedRoute,
private stateService: StateService,
@@ -141,9 +143,9 @@ export class WalletPreviewComponent implements OnInit, OnDestroy {
map((params: ParamMap) => params.get('wallet') as string),
tap((walletName: string) => {
this.walletName = walletName;
- this.openGraphService.waitFor('wallet-addresses-' + this.walletName);
- this.openGraphService.waitFor('wallet-data-' + this.walletName);
- this.openGraphService.waitFor('wallet-txs-' + this.walletName);
+ this.ogSession = this.openGraphService.waitFor('wallet-addresses-' + this.walletName);
+ this.ogSession = this.openGraphService.waitFor('wallet-data-' + this.walletName);
+ this.ogSession = this.openGraphService.waitFor('wallet-txs-' + this.walletName);
this.seoService.setTitle($localize`:@@wallet.component.browser-title:Wallet: ${walletName}:INTERPOLATION:`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.wallet:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} wallet ${walletName}:INTERPOLATION:.`);
}),
@@ -152,9 +154,9 @@ export class WalletPreviewComponent implements OnInit, OnDestroy {
this.error = err;
this.seoService.logSoft404();
console.log(err);
- this.openGraphService.fail('wallet-addresses-' + this.walletName);
- this.openGraphService.fail('wallet-data-' + this.walletName);
- this.openGraphService.fail('wallet-txs-' + this.walletName);
+ this.openGraphService.fail({ event: 'wallet-addresses-' + this.walletName, sessionId: this.ogSession });
+ this.openGraphService.fail({ event: 'wallet-data-' + this.walletName, sessionId: this.ogSession });
+ this.openGraphService.fail({ event: 'wallet-txs-' + this.walletName, sessionId: this.ogSession });
return of({});
})
)),
@@ -185,13 +187,13 @@ export class WalletPreviewComponent implements OnInit, OnDestroy {
this.walletSubscription = this.walletAddresses$.subscribe(wallet => {
this.addressStrings = Object.keys(wallet);
this.addresses = Object.values(wallet);
- this.openGraphService.waitOver('wallet-addresses-' + this.walletName);
+ this.openGraphService.waitOver({ event: 'wallet-addresses-' + this.walletName, sessionId: this.ogSession });
});
this.walletSummary$ = this.wallet$.pipe(
map(wallet => this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))),
tap(() => {
- this.openGraphService.waitOver('wallet-txs-' + this.walletName);
+ this.openGraphService.waitOver({ event: 'wallet-txs-' + this.walletName, sessionId: this.ogSession });
})
);
@@ -209,7 +211,7 @@ export class WalletPreviewComponent implements OnInit, OnDestroy {
);
}),
tap(() => {
- this.openGraphService.waitOver('wallet-data-' + this.walletName);
+ this.openGraphService.waitOver({ event: 'wallet-data-' + this.walletName, sessionId: this.ogSession });
})
);
}
diff --git a/frontend/src/app/lightning/channel/channel-preview.component.ts b/frontend/src/app/lightning/channel/channel-preview.component.ts
index 84a85f9c6..2af0dcd57 100644
--- a/frontend/src/app/lightning/channel/channel-preview.component.ts
+++ b/frontend/src/app/lightning/channel/channel-preview.component.ts
@@ -18,6 +18,8 @@ export class ChannelPreviewComponent implements OnInit {
channelGeo: number[] = [];
shortId: string;
+ ogSession: number;
+
constructor(
private lightningApiService: LightningApiService,
private activatedRoute: ActivatedRoute,
@@ -30,8 +32,8 @@ export class ChannelPreviewComponent implements OnInit {
.pipe(
switchMap((params: ParamMap) => {
this.shortId = params.get('short_id') || '';
- this.openGraphService.waitFor('channel-map-' + this.shortId);
- this.openGraphService.waitFor('channel-data-' + this.shortId);
+ this.ogSession = this.openGraphService.waitFor('channel-map-' + this.shortId);
+ this.ogSession = this.openGraphService.waitFor('channel-data-' + this.shortId);
this.error = null;
this.seoService.setTitle(`Channel: ${params.get('short_id')}`);
this.seoService.setDescription($localize`:@@meta.description.lightning.channel:Overview for Lightning channel ${params.get('short_id')}. See channel capacity, the Lightning nodes involved, related on-chain transactions, and more.`);
@@ -51,13 +53,13 @@ export class ChannelPreviewComponent implements OnInit {
data.node_right.longitude, data.node_right.latitude,
];
}
- this.openGraphService.waitOver('channel-data-' + this.shortId);
+ this.openGraphService.waitOver({ event: 'channel-data-' + this.shortId, sessionId: this.ogSession });
}),
catchError((err) => {
this.error = err;
this.seoService.logSoft404();
- this.openGraphService.fail('channel-map-' + this.shortId);
- this.openGraphService.fail('channel-data-' + this.shortId);
+ this.openGraphService.fail({ event: 'channel-map-' + this.shortId, sessionId: this.ogSession });
+ this.openGraphService.fail({ event: 'channel-data-' + this.shortId, sessionId: this.ogSession });
return of(null);
})
);
@@ -66,6 +68,6 @@ export class ChannelPreviewComponent implements OnInit {
}
onMapReady() {
- this.openGraphService.waitOver('channel-map-' + this.shortId);
+ this.openGraphService.waitOver({ event: 'channel-map-' + this.shortId, sessionId: this.ogSession });
}
}
diff --git a/frontend/src/app/lightning/group/group-preview.component.ts b/frontend/src/app/lightning/group/group-preview.component.ts
index 4b8f5ed77..4e7d56bbe 100644
--- a/frontend/src/app/lightning/group/group-preview.component.ts
+++ b/frontend/src/app/lightning/group/group-preview.component.ts
@@ -22,6 +22,8 @@ export class GroupPreviewComponent implements OnInit {
slug: string;
groupId: string;
+ ogSession: number;
+
constructor(
private lightningApiService: LightningApiService,
private activatedRoute: ActivatedRoute,
@@ -37,8 +39,8 @@ export class GroupPreviewComponent implements OnInit {
.pipe(
switchMap((params: ParamMap) => {
this.slug = params.get('slug');
- this.openGraphService.waitFor('ln-group-map-' + this.slug);
- this.openGraphService.waitFor('ln-group-data-' + this.slug);
+ this.ogSession = this.openGraphService.waitFor('ln-group-map-' + this.slug);
+ this.ogSession = this.openGraphService.waitFor('ln-group-data-' + this.slug);
if (this.slug === 'the-mempool-open-source-project') {
this.groupId = 'mempool.space';
@@ -52,8 +54,8 @@ export class GroupPreviewComponent implements OnInit {
description: '',
};
this.seoService.logSoft404();
- this.openGraphService.fail('ln-group-map-' + this.slug);
- this.openGraphService.fail('ln-group-data-' + this.slug);
+ this.openGraphService.fail({ event: 'ln-group-map-' + this.slug, sessionId: this.ogSession });
+ this.openGraphService.fail({ event: 'ln-group-data-' + this.slug, sessionId: this.ogSession });
return of(null);
}
@@ -99,7 +101,7 @@ export class GroupPreviewComponent implements OnInit {
const sumLiquidity = nodes.reduce((partialSum, a) => partialSum + parseInt(a.capacity, 10), 0);
const sumChannels = nodes.reduce((partialSum, a) => partialSum + a.opened_channel_count, 0);
- this.openGraphService.waitOver('ln-group-data-' + this.slug);
+ this.openGraphService.waitOver({ event: 'ln-group-data-' + this.slug, sessionId: this.ogSession });
return {
nodes: nodes,
@@ -109,8 +111,8 @@ export class GroupPreviewComponent implements OnInit {
}),
catchError(() => {
this.seoService.logSoft404();
- this.openGraphService.fail('ln-group-map-' + this.slug);
- this.openGraphService.fail('ln-group-data-' + this.slug);
+ this.openGraphService.fail({ event: 'ln-group-map-' + this.slug, sessionId: this.ogSession });
+ this.openGraphService.fail({ event: 'ln-group-data-' + this.slug, sessionId: this.ogSession });
return of({
nodes: [],
sumLiquidity: 0,
@@ -121,7 +123,7 @@ export class GroupPreviewComponent implements OnInit {
}
onMapReady(): void {
- this.openGraphService.waitOver('ln-group-map-' + this.slug);
+ this.openGraphService.waitOver({ event: 'ln-group-map-' + this.slug, sessionId: this.ogSession });
}
}
diff --git a/frontend/src/app/lightning/node/node-preview.component.ts b/frontend/src/app/lightning/node/node-preview.component.ts
index 259313de6..7a45ea905 100644
--- a/frontend/src/app/lightning/node/node-preview.component.ts
+++ b/frontend/src/app/lightning/node/node-preview.component.ts
@@ -27,6 +27,8 @@ export class NodePreviewComponent implements OnInit {
publicKeySize = 99;
+ ogSession: number;
+
constructor(
private lightningApiService: LightningApiService,
private activatedRoute: ActivatedRoute,
@@ -43,8 +45,8 @@ export class NodePreviewComponent implements OnInit {
.pipe(
switchMap((params: ParamMap) => {
this.publicKey = params.get('public_key');
- this.openGraphService.waitFor('node-map-' + this.publicKey);
- this.openGraphService.waitFor('node-data-' + this.publicKey);
+ this.ogSession = this.openGraphService.waitFor('node-map-' + this.publicKey);
+ this.ogSession = this.openGraphService.waitFor('node-data-' + this.publicKey);
return this.lightningApiService.getNode$(params.get('public_key'));
}),
map((node) => {
@@ -76,15 +78,15 @@ export class NodePreviewComponent implements OnInit {
this.socketTypes = Object.keys(socketTypesMap);
node.avgCapacity = node.capacity / Math.max(1, node.active_channel_count);
- this.openGraphService.waitOver('node-data-' + this.publicKey);
+ this.openGraphService.waitOver({ event: 'node-data-' + this.publicKey, sessionId: this.ogSession });
return node;
}),
catchError(err => {
this.error = err;
this.seoService.logSoft404();
- this.openGraphService.fail('node-map-' + this.publicKey);
- this.openGraphService.fail('node-data-' + this.publicKey);
+ this.openGraphService.fail({ event: 'node-map-' + this.publicKey, sessionId: this.ogSession });
+ this.openGraphService.fail({ event: 'node-data-' + this.publicKey, sessionId: this.ogSession });
return [{
alias: this.publicKey,
public_key: this.publicKey,
@@ -102,6 +104,6 @@ export class NodePreviewComponent implements OnInit {
}
onMapReady() {
- this.openGraphService.waitOver('node-map-' + this.publicKey);
+ this.openGraphService.waitOver({ event: 'node-map-' + this.publicKey, sessionId: this.ogSession });
}
}
diff --git a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp-preview.component.ts b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp-preview.component.ts
index 9fc071eb5..bab34ae8f 100644
--- a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp-preview.component.ts
+++ b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp-preview.component.ts
@@ -19,6 +19,8 @@ export class NodesPerISPPreview implements OnInit {
id: string;
error: Error;
+ ogSession: number;
+
constructor(
private apiService: ApiService,
private seoService: SeoService,
@@ -32,8 +34,8 @@ export class NodesPerISPPreview implements OnInit {
switchMap((params: ParamMap) => {
this.id = params.get('isp');
this.isp = null;
- this.openGraphService.waitFor('isp-map-' + this.id);
- this.openGraphService.waitFor('isp-data-' + this.id);
+ this.ogSession = this.openGraphService.waitFor('isp-map-' + this.id);
+ this.ogSession = this.openGraphService.waitFor('isp-data-' + this.id);
return this.apiService.getNodeForISP$(params.get('isp'));
}),
map(response => {
@@ -75,7 +77,7 @@ export class NodesPerISPPreview implements OnInit {
}
topCountry.flag = getFlagEmoji(topCountry.iso);
- this.openGraphService.waitOver('isp-data-' + this.id);
+ this.openGraphService.waitOver({ event: 'isp-data-' + this.id, sessionId: this.ogSession });
return {
nodes: response.nodes,
@@ -87,8 +89,8 @@ export class NodesPerISPPreview implements OnInit {
catchError(err => {
this.error = err;
this.seoService.logSoft404();
- this.openGraphService.fail('isp-map-' + this.id);
- this.openGraphService.fail('isp-data-' + this.id);
+ this.openGraphService.fail({ event: 'isp-map-' + this.id, sessionId: this.ogSession });
+ this.openGraphService.fail({ event: 'isp-data-' + this.id, sessionId: this.ogSession });
return of({
nodes: [],
sumLiquidity: 0,
@@ -100,6 +102,6 @@ export class NodesPerISPPreview implements OnInit {
}
onMapReady() {
- this.openGraphService.waitOver('isp-map-' + this.id);
+ this.openGraphService.waitOver({ event: 'isp-map-' + this.id, sessionId: this.ogSession });
}
}
diff --git a/frontend/src/app/route-guards.ts b/frontend/src/app/route-guards.ts
index 780e997db..81cbf03ae 100644
--- a/frontend/src/app/route-guards.ts
+++ b/frontend/src/app/route-guards.ts
@@ -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));
}
}
diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts
index 3c8cf8807..d958bfa25 100644
--- a/frontend/src/app/services/api.service.ts
+++ b/frontend/src/app/services/api.service.ts
@@ -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 {
+ return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints);
+ }
+
+ getCpfpLocalTx$(tx: any[]): Observable {
+ return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/cpfp', tx);
+ }
+
// Cache methods
async setBlockAuditLoaded(hash: string) {
this.blockAuditLoaded[hash] = true;
diff --git a/frontend/src/app/services/opengraph.service.ts b/frontend/src/app/services/opengraph.service.ts
index e969dd07a..47b9d87d4 100644
--- a/frontend/src/app/services/opengraph.service.ts
+++ b/frontend/src/app/services/opengraph.service.ts
@@ -12,8 +12,9 @@ import { LanguageService } from '@app/services/language.service';
export class OpenGraphService {
network = '';
defaultImageUrl = '';
- previewLoadingEvents = {};
- previewLoadingCount = 0;
+ previewLoadingEvents = {}; // pending count per event type
+ previewLoadingCount = 0; // number of unique events pending
+ sessionId = 1;
constructor(
private ngZone: NgZone,
@@ -45,7 +46,7 @@ export class OpenGraphService {
// expose routing method to global scope, so we can access it from the unfurler
window['ogService'] = {
- loadPage: (path) => { return this.loadPage(path) }
+ loadPage: (path) => { return this.loadPage(path); }
};
}
@@ -77,7 +78,7 @@ export class OpenGraphService {
}
/// register an event that needs to resolve before we can take a screenshot
- waitFor(event) {
+ waitFor(event: string): number {
if (!this.previewLoadingEvents[event]) {
this.previewLoadingEvents[event] = 1;
this.previewLoadingCount++;
@@ -85,24 +86,31 @@ export class OpenGraphService {
this.previewLoadingEvents[event]++;
}
this.metaService.updateTag({ property: 'og:preview:loading', content: 'loading'});
+ return this.sessionId;
}
// mark an event as resolved
// if all registered events have resolved, signal we are ready for a screenshot
- waitOver(event) {
+ waitOver({ event, sessionId }: { event: string, sessionId: number }) {
+ if (sessionId !== this.sessionId) {
+ return;
+ }
if (this.previewLoadingEvents[event]) {
this.previewLoadingEvents[event]--;
if (this.previewLoadingEvents[event] === 0 && this.previewLoadingCount > 0) {
- delete this.previewLoadingEvents[event]
+ delete this.previewLoadingEvents[event];
this.previewLoadingCount--;
}
- if (this.previewLoadingCount === 0) {
- this.metaService.updateTag({ property: 'og:preview:ready', content: 'ready'});
- }
+ }
+ if (this.previewLoadingCount === 0) {
+ this.metaService.updateTag({ property: 'og:preview:ready', content: 'ready'});
}
}
- fail(event) {
+ fail({ event, sessionId }: { event: string, sessionId: number }) {
+ if (sessionId !== this.sessionId) {
+ return;
+ }
if (this.previewLoadingEvents[event]) {
this.metaService.updateTag({ property: 'og:preview:fail', content: 'fail'});
}
@@ -111,6 +119,7 @@ export class OpenGraphService {
resetLoading() {
this.previewLoadingEvents = {};
this.previewLoadingCount = 0;
+ this.sessionId++;
this.metaService.removeTag("property='og:preview:loading'");
this.metaService.removeTag("property='og:preview:ready'");
this.metaService.removeTag("property='og:preview:fail'");
@@ -122,7 +131,7 @@ export class OpenGraphService {
this.resetLoading();
this.ngZone.run(() => {
this.router.navigateByUrl(path);
- })
+ });
}
}
}
diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html
index 24e5c73ae..0dcea9245 100644
--- a/frontend/src/app/shared/components/global-footer/global-footer.component.html
+++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html
@@ -76,6 +76,7 @@
Recent Blocks
Broadcast Transaction
Test Transaction
+ Preview Transaction
Connect to our Nodes
API Documentation
@@ -85,6 +86,7 @@
What is a block explorer?
What is a mempool explorer?
Why isn't my transaction confirming?
+ Be your own explorer™
More FAQs »
Research
diff --git a/frontend/src/app/shared/components/truncate/truncate.component.html b/frontend/src/app/shared/components/truncate/truncate.component.html
index 066f83244..b7e31483e 100644
--- a/frontend/src/app/shared/components/truncate/truncate.component.html
+++ b/frontend/src/app/shared/components/truncate/truncate.component.html
@@ -1,6 +1,6 @@
-
+
diff --git a/frontend/src/app/shared/components/truncate/truncate.component.scss b/frontend/src/app/shared/components/truncate/truncate.component.scss
index 8c22dd836..739376ed2 100644
--- a/frontend/src/app/shared/components/truncate/truncate.component.scss
+++ b/frontend/src/app/shared/components/truncate/truncate.component.scss
@@ -37,6 +37,12 @@
max-width: 300px;
overflow: hidden;
}
+
+ .disabled {
+ pointer-events: none;
+ opacity: 0.8;
+ color: #fff;
+ }
}
@media (max-width: 567px) {
diff --git a/frontend/src/app/shared/components/truncate/truncate.component.ts b/frontend/src/app/shared/components/truncate/truncate.component.ts
index 589f7aa36..f9ab34ee9 100644
--- a/frontend/src/app/shared/components/truncate/truncate.component.ts
+++ b/frontend/src/app/shared/components/truncate/truncate.component.ts
@@ -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(
diff --git a/frontend/src/app/shared/script.utils.ts b/frontend/src/app/shared/script.utils.ts
index 731e0051b..62a7a5845 100644
--- a/frontend/src/app/shared/script.utils.ts
+++ b/frontend/src/app/shared/script.utils.ts
@@ -251,6 +251,11 @@ 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);
+ }
+
return;
}
@@ -299,6 +304,62 @@ 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, one pubkey group (3 tokens) + m push + final opcode = 5 tokens
+ if (ops.length < 5) return;
+
+ const finalOp = ops.pop();
+ if (finalOp !== 'OP_NUMEQUAL' && finalOp !== 'OP_GREATERTHANOREQUAL') {
+ 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 (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 getVarIntLength(n: number): number {
if (n < 0xfd) {
return 1;
diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts
index 76184113f..d937e6bbb 100644
--- a/frontend/src/app/shared/shared.module.ts
+++ b/frontend/src/app/shared/shared.module.ts
@@ -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 } from '@fortawesome/free-solid-svg-icons';
+ faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope } 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';
@@ -464,5 +464,6 @@ export class SharedModule {
library.addIcons(faRobot);
library.addIcons(faShareNodes);
library.addIcons(faCreditCard);
+ library.addIcons(faMicroscope);
}
}
diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts
index b3678986b..eafe8ae99 100644
--- a/frontend/src/app/shared/transaction.utils.ts
+++ b/frontend/src/app/shared/transaction.utils.ts
@@ -1,8 +1,9 @@
import { TransactionFlags } from '@app/shared/filters.utils';
-import { getVarIntLength, opcodes, parseMultisigScript, isPoint } from '@app/shared/script.utils';
-import { Transaction } from '@interfaces/electrs.interface';
+import { getVarIntLength, parseMultisigScript, isPoint } from '@app/shared/script.utils';
+import { Transaction, Vin } from '@interfaces/electrs.interface';
import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface';
import { StateService } from '@app/services/state.service';
+import { Hash } from './sha256';
// Bitcoin Core default policy settings
const MAX_STANDARD_TX_WEIGHT = 400_000;
@@ -588,3 +589,1053 @@ export function identifyPrioritizedTransactions(transactions: TransactionStrippe
return { prioritized, deprioritized };
}
+
+// Adapted from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L254
+// Converts hex bitcoin script to ASM
+function convertScriptSigAsm(hex: string): string {
+
+ const buf = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < buf.length; i++) {
+ buf[i] = parseInt(hex.substr(i * 2, 2), 16);
+ }
+
+ const b = [];
+ let i = 0;
+
+ while (i < buf.length) {
+ const op = buf[i];
+ if (op >= 0x01 && op <= 0x4e) {
+ i++;
+ let push;
+ if (op === 0x4c) {
+ push = buf[i];
+ b.push('OP_PUSHDATA1');
+ i += 1;
+ } else if (op === 0x4d) {
+ push = buf[i] | (buf[i + 1] << 8);
+ b.push('OP_PUSHDATA2');
+ i += 2;
+ } else if (op === 0x4e) {
+ push = buf[i] | (buf[i + 1] << 8) | (buf[i + 2] << 16) | (buf[i + 3] << 24);
+ b.push('OP_PUSHDATA4');
+ i += 4;
+ } else {
+ push = op;
+ b.push('OP_PUSHBYTES_' + push);
+ }
+
+ const data = buf.slice(i, i + push);
+ if (data.length !== push) {
+ break;
+ }
+
+ b.push(uint8ArrayToHexString(data));
+ i += data.length;
+ } else {
+ if (op === 0x00) {
+ b.push('OP_0');
+ } else if (op === 0x4f) {
+ b.push('OP_PUSHNUM_NEG1');
+ } else if (op === 0xb1) {
+ b.push('OP_CLTV');
+ } else if (op === 0xb2) {
+ b.push('OP_CSV');
+ } else if (op === 0xba) {
+ b.push('OP_CHECKSIGADD');
+ } else {
+ const opcode = opcodes[op];
+ if (opcode) {
+ b.push(opcode);
+ } else {
+ b.push('OP_RETURN_' + op);
+ }
+ }
+ i += 1;
+ }
+ }
+
+ return b.join(' ');
+}
+
+// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L327
+/**
+ * This function must only be called when we know the witness we are parsing
+ * is a taproot witness.
+ * @param witness An array of hex strings that represents the witness stack of
+ * the input.
+ * @returns null if the witness is not a script spend, and the hex string of
+ * the script item if it is a script spend.
+ */
+function witnessToP2TRScript(witness: string[]): string | null {
+ if (witness.length < 2) return null;
+ // Note: see BIP341 for parsing details of witness stack
+
+ // If there are at least two witness elements, and the first byte of the
+ // last element is 0x50, this last element is called annex a and
+ // is removed from the witness stack.
+ const hasAnnex = witness[witness.length - 1].substring(0, 2) === '50';
+ // If there are at least two witness elements left, script path spending is used.
+ // Call the second-to-last stack element s, the script.
+ // (Note: this phrasing from BIP341 assumes we've *removed* the annex from the stack)
+ if (hasAnnex && witness.length < 3) return null;
+ const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2;
+ return witness[positionOfScript];
+}
+
+// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L227
+// Fills inner_redeemscript_asm and inner_witnessscript_asm fields of fetched prevouts for decoded transactions
+export function addInnerScriptsToVin(vin: Vin): void {
+ if (!vin.prevout) {
+ return;
+ }
+
+ if (vin.prevout.scriptpubkey_type === 'p2sh') {
+ const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
+ vin.inner_redeemscript_asm = convertScriptSigAsm(redeemScript);
+ if (vin.witness && vin.witness.length) {
+ const witnessScript = vin.witness[vin.witness.length - 1];
+ vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript);
+ }
+ }
+
+ if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) {
+ const witnessScript = vin.witness[vin.witness.length - 1];
+ vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript);
+ }
+
+ if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) {
+ const witnessScript = witnessToP2TRScript(vin.witness);
+ if (witnessScript !== null) {
+ vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript);
+ }
+ }
+}
+
+// Adapted from bitcoinjs-lib at https://github.com/bitcoinjs/bitcoinjs-lib/blob/32e08aa57f6a023e995d8c4f0c9fbdc5f11d1fa0/ts_src/transaction.ts#L78
+/**
+ * @param buffer The raw transaction data
+ * @param network
+ * @param inputs Additional information from a PSBT, if available
+ * @returns The decoded transaction object and the raw hex
+ */
+function fromBuffer(buffer: Uint8Array, network: string, inputs?: { key: Uint8Array; value: Uint8Array }[][]): { tx: Transaction, hex: string } {
+ let offset = 0;
+
+ // Parse raw transaction
+ const tx = {
+ status: {
+ confirmed: null,
+ block_height: null,
+ block_hash: null,
+ block_time: null,
+ }
+ } as Transaction;
+
+ [tx.version, offset] = readInt32(buffer, offset);
+
+ let marker, flag;
+ [marker, offset] = readInt8(buffer, offset);
+ [flag, offset] = readInt8(buffer, offset);
+
+ let isLegacyTransaction = true;
+ if (marker === 0x00 && flag === 0x01) {
+ isLegacyTransaction = false;
+ } else {
+ offset -= 2;
+ }
+
+ let vinLen;
+ [vinLen, offset] = readVarInt(buffer, offset);
+ if (vinLen === 0) {
+ throw new Error('Transaction has no inputs');
+ }
+ tx.vin = [];
+ for (let i = 0; i < vinLen; ++i) {
+ let txid, vout, scriptsig, sequence;
+ [txid, offset] = readSlice(buffer, offset, 32);
+ txid = uint8ArrayToHexString(txid.reverse());
+ [vout, offset] = readInt32(buffer, offset, true);
+ [scriptsig, offset] = readVarSlice(buffer, offset);
+ scriptsig = uint8ArrayToHexString(scriptsig);
+ [sequence, offset] = readInt32(buffer, offset, true);
+ const is_coinbase = txid === '0'.repeat(64);
+ const scriptsig_asm = convertScriptSigAsm(scriptsig);
+ tx.vin.push({ txid, vout, scriptsig, sequence, is_coinbase, scriptsig_asm, prevout: null });
+ }
+
+ let voutLen;
+ [voutLen, offset] = readVarInt(buffer, offset);
+ tx.vout = [];
+ for (let i = 0; i < voutLen; ++i) {
+ let value, scriptpubkeyArray, scriptpubkey;
+ [value, offset] = readInt64(buffer, offset);
+ value = Number(value);
+ [scriptpubkeyArray, offset] = readVarSlice(buffer, offset);
+ scriptpubkey = uint8ArrayToHexString(scriptpubkeyArray);
+ const scriptpubkey_asm = convertScriptSigAsm(scriptpubkey);
+ const toAddress = scriptPubKeyToAddress(scriptpubkey, network);
+ const scriptpubkey_type = toAddress.type;
+ const scriptpubkey_address = toAddress?.address;
+ tx.vout.push({ value, scriptpubkey, scriptpubkey_asm, scriptpubkey_type, scriptpubkey_address });
+ }
+
+ if (!isLegacyTransaction) {
+ for (let i = 0; i < vinLen; ++i) {
+ let witness;
+ [witness, offset] = readVector(buffer, offset);
+ tx.vin[i].witness = witness.map(uint8ArrayToHexString);
+ }
+ }
+
+ [tx.locktime, offset] = readInt32(buffer, offset, true);
+
+ if (offset !== buffer.length) {
+ throw new Error('Transaction has unexpected data');
+ }
+
+ // Optionally add data from PSBT: prevouts, redeem/witness scripts and signatures
+ if (inputs) {
+ for (let i = 0; i < tx.vin.length; i++) {
+ const vin = tx.vin[i];
+ const inputRecords = inputs[i];
+
+ const groups = {
+ nonWitnessUtxo: null,
+ witnessUtxo: null,
+ finalScriptSig: null,
+ finalScriptWitness: null,
+ redeemScript: null,
+ witnessScript: null,
+ partialSigs: []
+ };
+
+ for (const record of inputRecords) {
+ switch (record.key[0]) {
+ case 0x00:
+ groups.nonWitnessUtxo = record;
+ break;
+ case 0x01:
+ groups.witnessUtxo = record;
+ break;
+ case 0x07:
+ groups.finalScriptSig = record;
+ break;
+ case 0x08:
+ groups.finalScriptWitness = record;
+ break;
+ case 0x04:
+ groups.redeemScript = record;
+ break;
+ case 0x05:
+ groups.witnessScript = record;
+ break;
+ case 0x02:
+ groups.partialSigs.push(record);
+ break;
+ }
+ }
+
+ // Fill prevout
+ if (groups.witnessUtxo && !vin.prevout) {
+ let value, scriptpubkeyArray, scriptpubkey, outputOffset = 0;
+ [value, outputOffset] = readInt64(groups.witnessUtxo.value, outputOffset);
+ value = Number(value);
+ [scriptpubkeyArray, outputOffset] = readVarSlice(groups.witnessUtxo.value, outputOffset);
+ scriptpubkey = uint8ArrayToHexString(scriptpubkeyArray);
+ const scriptpubkey_asm = convertScriptSigAsm(scriptpubkey);
+ const toAddress = scriptPubKeyToAddress(scriptpubkey, network);
+ const scriptpubkey_type = toAddress.type;
+ const scriptpubkey_address = toAddress?.address;
+ vin.prevout = { value, scriptpubkey, scriptpubkey_asm, scriptpubkey_type, scriptpubkey_address };
+ }
+ if (groups.nonWitnessUtxo && !vin.prevout) {
+ const utxoTx = fromBuffer(groups.nonWitnessUtxo.value, network).tx;
+ vin.prevout = utxoTx.vout[vin.vout];
+ }
+
+ // Fill final scriptSig or witness
+ let finalizedScriptSig = false;
+ if (groups.finalScriptSig) {
+ vin.scriptsig = uint8ArrayToHexString(groups.finalScriptSig.value);
+ vin.scriptsig_asm = convertScriptSigAsm(vin.scriptsig);
+ finalizedScriptSig = true;
+ }
+ let finalizedWitness = false;
+ if (groups.finalScriptWitness) {
+ let witness = [];
+ let witnessOffset = 0;
+ [witness, witnessOffset] = readVector(groups.finalScriptWitness.value, witnessOffset);
+ vin.witness = witness.map(uint8ArrayToHexString);
+ finalizedWitness = true;
+ }
+ if (finalizedScriptSig && finalizedWitness) {
+ continue;
+ }
+
+ // Fill redeem script and/or witness script
+ if (groups.redeemScript && !finalizedScriptSig) {
+ const redeemScript = groups.redeemScript.value;
+ if (redeemScript.length > 520) {
+ throw new Error("Redeem script must be <= 520 bytes");
+ }
+ let pushOpcode;
+ if (redeemScript.length < 0x4c) {
+ pushOpcode = new Uint8Array([redeemScript.length]);
+ } else if (redeemScript.length <= 0xff) {
+ pushOpcode = new Uint8Array([0x4c, redeemScript.length]); // OP_PUSHDATA1
+ } else {
+ pushOpcode = new Uint8Array([0x4d, redeemScript.length & 0xff, redeemScript.length >> 8]); // OP_PUSHDATA2
+ }
+ vin.scriptsig = (vin.scriptsig || '') + uint8ArrayToHexString(pushOpcode) + uint8ArrayToHexString(redeemScript);
+ vin.scriptsig_asm = convertScriptSigAsm(vin.scriptsig);
+ }
+ if (groups.witnessScript && !finalizedWitness) {
+ vin.witness = (vin.witness || []).concat(uint8ArrayToHexString(groups.witnessScript.value));
+ }
+
+
+ // Fill partial signatures
+ for (const record of groups.partialSigs) {
+ const scriptpubkey_type = vin.prevout?.scriptpubkey_type;
+ if (scriptpubkey_type === 'v0_p2wsh' && !finalizedWitness) {
+ vin.witness = vin.witness || [];
+ vin.witness.unshift(uint8ArrayToHexString(record.value));
+ }
+ if (scriptpubkey_type === 'p2sh') {
+ const redeemScriptStr = vin.scriptsig_asm ? vin.scriptsig_asm.split(' ').reverse()[0] : '';
+ if (redeemScriptStr.startsWith('00') && redeemScriptStr.length === 68 && vin.witness?.length) {
+ if (!finalizedWitness) {
+ vin.witness.unshift(uint8ArrayToHexString(record.value));
+ }
+ } else {
+ if (!finalizedScriptSig) {
+ const signature = record.value;
+ if (signature.length > 73) {
+ throw new Error("Signature must be <= 73 bytes");
+ }
+ const pushOpcode = new Uint8Array([signature.length]);
+ vin.scriptsig = uint8ArrayToHexString(pushOpcode) + uint8ArrayToHexString(signature) + (vin.scriptsig || '');
+ vin.scriptsig_asm = convertScriptSigAsm(vin.scriptsig);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Calculate final size, weight, and txid
+ const hasWitness = tx.vin.some(vin => vin.witness?.length);
+ let witnessSize = 0;
+ if (hasWitness) {
+ for (let i = 0; i < tx.vin.length; ++i) {
+ const witnessItems = tx.vin[i].witness || [];
+ witnessSize += getVarIntLength(witnessItems.length);
+ for (const item of witnessItems) {
+ const witnessItem = hexStringToUint8Array(item);
+ witnessSize += getVarIntLength(witnessItem.length);
+ witnessSize += witnessItem.length;
+ }
+ }
+ witnessSize += 2;
+ }
+
+ const rawHex = serializeTransaction(tx, hasWitness);
+ tx.size = rawHex.length;
+ tx.weight = (tx.size - witnessSize) * 3 + tx.size;
+ tx.txid = txid(tx);
+
+ return { tx, hex: uint8ArrayToHexString(rawHex) };
+}
+
+/**
+ * Decodes a PSBT buffer into the unsigned raw transaction and input map
+ * @param psbtBuffer
+ * @returns
+ * - the unsigned transaction from a PSBT (txHex)
+ * - the full input map for each input in to fill signatures and prevouts later (inputs)
+ */
+function decodePsbt(psbtBuffer: Uint8Array): { rawTx: Uint8Array; inputs: { key: Uint8Array; value: Uint8Array }[][] } {
+ let offset = 0;
+
+ // magic: "psbt" in ASCII
+ const expectedMagic = [0x70, 0x73, 0x62, 0x74];
+ for (let i = 0; i < expectedMagic.length; i++) {
+ if (psbtBuffer[offset + i] !== expectedMagic[i]) {
+ throw new Error("Invalid PSBT magic bytes");
+ }
+ }
+ offset += expectedMagic.length;
+
+ const separator = psbtBuffer[offset];
+ offset += 1;
+ if (separator !== 0xff) {
+ throw new Error("Invalid PSBT separator");
+ }
+
+ // GLOBAL MAP
+ let rawTx: Uint8Array | null = null;
+ while (offset < psbtBuffer.length) {
+ const [keyLen, newOffset] = readVarInt(psbtBuffer, offset);
+ offset = newOffset;
+ // key length of 0 means the end of the global map
+ if (keyLen === 0) {
+ break;
+ }
+ const key = psbtBuffer.slice(offset, offset + keyLen);
+ offset += keyLen;
+ const [valLen, newOffset2] = readVarInt(psbtBuffer, offset);
+ offset = newOffset2;
+ const value = psbtBuffer.slice(offset, offset + valLen);
+ offset += valLen;
+
+ // Global key type 0x00 holds the unsigned transaction.
+ if (key[0] === 0x00) {
+ rawTx = value;
+ }
+ }
+
+ if (!rawTx) {
+ throw new Error("Unsigned transaction not found in PSBT");
+ }
+
+ let numInputs: number;
+ let txOffset = 0;
+ // Skip version (4 bytes)
+ txOffset += 4;
+ if (rawTx[txOffset] === 0x00 && rawTx[txOffset + 1] === 0x01) {
+ txOffset += 2;
+ }
+ const [inputCount, newTxOffset] = readVarInt(rawTx, txOffset);
+ txOffset = newTxOffset;
+ numInputs = inputCount;
+
+ // INPUT MAPS
+ const inputs: { key: Uint8Array; value: Uint8Array }[][] = [];
+ for (let i = 0; i < numInputs; i++) {
+ const inputRecords: { key: Uint8Array; value: Uint8Array }[] = [];
+ const seenKeys = new Set();
+ while (offset < psbtBuffer.length) {
+ const [keyLen, newOffset] = readVarInt(psbtBuffer, offset);
+ offset = newOffset;
+ // key length of 0 means the end of the input map
+ if (keyLen === 0) {
+ break;
+ }
+ const key = psbtBuffer.slice(offset, offset + keyLen);
+ offset += keyLen;
+
+ const keyHex = uint8ArrayToHexString(key);
+ if (seenKeys.has(keyHex)) {
+ throw new Error(`Duplicate key in input map`);
+ }
+ seenKeys.add(keyHex);
+
+ const [valLen, newOffset2] = readVarInt(psbtBuffer, offset);
+ offset = newOffset2;
+ const value = psbtBuffer.slice(offset, offset + valLen);
+ offset += valLen;
+
+ inputRecords.push({ key, value });
+ }
+ inputs.push(inputRecords);
+ }
+
+ return { rawTx, inputs };
+}
+
+export function decodeRawTransaction(input: string, network: string): { tx: Transaction, hex: string } {
+ if (!input.length) {
+ throw new Error('Empty input');
+ }
+
+ let buffer: Uint8Array;
+ if (input.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(input)) {
+ buffer = hexStringToUint8Array(input);
+ } else if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)|[A-Za-z0-9+/]{3}=)?$/.test(input)) {
+ buffer = base64ToUint8Array(input);
+ } else {
+ throw new Error('Invalid input: not a valid transaction or PSBT');
+ }
+
+ if (buffer[0] === 0x70 && buffer[1] === 0x73 && buffer[2] === 0x62 && buffer[3] === 0x74) { // PSBT magic bytes
+ const { rawTx, inputs } = decodePsbt(buffer);
+ return fromBuffer(rawTx, network, inputs);
+ }
+
+ return fromBuffer(buffer, network);
+}
+
+function serializeTransaction(tx: Transaction, includeWitness: boolean = true): Uint8Array {
+ const result: number[] = [];
+
+ // Add version
+ result.push(...intToBytes(tx.version, 4));
+
+ if (includeWitness) {
+ // Add SegWit marker and flag bytes (0x00, 0x01)
+ result.push(0x00, 0x01);
+ }
+
+ // Add input count and inputs
+ result.push(...varIntToBytes(tx.vin.length));
+ for (const input of tx.vin) {
+ result.push(...hexStringToUint8Array(input.txid).reverse());
+ result.push(...intToBytes(input.vout, 4));
+ const scriptSig = hexStringToUint8Array(input.scriptsig);
+ result.push(...varIntToBytes(scriptSig.length));
+ result.push(...scriptSig);
+ result.push(...intToBytes(input.sequence, 4));
+ }
+
+ // Add output count and outputs
+ result.push(...varIntToBytes(tx.vout.length));
+ for (const output of tx.vout) {
+ result.push(...bigIntToBytes(BigInt(output.value), 8));
+ const scriptPubKey = hexStringToUint8Array(output.scriptpubkey);
+ result.push(...varIntToBytes(scriptPubKey.length));
+ result.push(...scriptPubKey);
+ }
+
+ if (includeWitness) {
+ for (const input of tx.vin) {
+ const witnessItems = input.witness || [];
+ result.push(...varIntToBytes(witnessItems.length));
+ for (const item of witnessItems) {
+ const witnessBytes = hexStringToUint8Array(item);
+ result.push(...varIntToBytes(witnessBytes.length));
+ result.push(...witnessBytes);
+ }
+ }
+ }
+
+ // Add locktime
+ result.push(...intToBytes(tx.locktime, 4));
+
+ return new Uint8Array(result);
+}
+
+function txid(tx: Transaction): string {
+ const serializedTx = serializeTransaction(tx, false);
+ const hash1 = new Hash().update(serializedTx).digest();
+ const hash2 = new Hash().update(hash1).digest();
+ return uint8ArrayToHexString(hash2.reverse());
+}
+
+// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L177
+export function countSigops(transaction: Transaction): number {
+ let sigops = 0;
+
+ for (const input of transaction.vin) {
+ if (input.scriptsig_asm) {
+ sigops += countScriptSigops(input.scriptsig_asm, true);
+ }
+ if (input.prevout) {
+ switch (true) {
+ case input.prevout.scriptpubkey_type === 'p2sh' && input.witness?.length === 2 && input.scriptsig && input.scriptsig.startsWith('160014'):
+ case input.prevout.scriptpubkey_type === 'v0_p2wpkh':
+ sigops += 1;
+ break;
+
+ case input.prevout?.scriptpubkey_type === 'p2sh' && input.witness?.length && input.scriptsig && input.scriptsig.startsWith('220020'):
+ case input.prevout.scriptpubkey_type === 'v0_p2wsh':
+ if (input.witness?.length) {
+ sigops += countScriptSigops(convertScriptSigAsm(input.witness[input.witness.length - 1]), false, true);
+ }
+ break;
+
+ case input.prevout.scriptpubkey_type === 'p2sh':
+ if (input.inner_redeemscript_asm) {
+ sigops += countScriptSigops(input.inner_redeemscript_asm);
+ }
+ break;
+ }
+ }
+ }
+
+ for (const output of transaction.vout) {
+ if (output.scriptpubkey_asm) {
+ sigops += countScriptSigops(output.scriptpubkey_asm, true);
+ }
+ }
+
+ return sigops;
+}
+
+function scriptPubKeyToAddress(scriptPubKey: string, network: string): { address: string, type: string } {
+ // P2PKH
+ if (/^76a914[0-9a-f]{40}88ac$/.test(scriptPubKey)) {
+ return { address: p2pkh(scriptPubKey.substring(6, 6 + 40), network), type: 'p2pkh' };
+ }
+ // P2PK
+ if (/^21[0-9a-f]{66}ac$/.test(scriptPubKey) || /^41[0-9a-f]{130}ac$/.test(scriptPubKey)) {
+ return { address: null, type: 'p2pk' };
+ }
+ // P2SH
+ if (/^a914[0-9a-f]{40}87$/.test(scriptPubKey)) {
+ return { address: p2sh(scriptPubKey.substring(4, 4 + 40), network), type: 'p2sh' };
+ }
+ // P2WPKH
+ if (/^0014[0-9a-f]{40}$/.test(scriptPubKey)) {
+ return { address: p2wpkh(scriptPubKey.substring(4, 4 + 40), network), type: 'v0_p2wpkh' };
+ }
+ // P2WSH
+ if (/^0020[0-9a-f]{64}$/.test(scriptPubKey)) {
+ return { address: p2wsh(scriptPubKey.substring(4, 4 + 64), network), type: 'v0_p2wsh' };
+ }
+ // P2TR
+ if (/^5120[0-9a-f]{64}$/.test(scriptPubKey)) {
+ return { address: p2tr(scriptPubKey.substring(4, 4 + 64), network), type: 'v1_p2tr' };
+ }
+ // multisig
+ if (/^[0-9a-f]+ae$/.test(scriptPubKey)) {
+ return { address: null, type: 'multisig' };
+ }
+ // anchor
+ if (scriptPubKey === '51024e73') {
+ return { address: p2a(network), type: 'anchor' };
+ }
+ // op_return
+ if (/^6a/.test(scriptPubKey)) {
+ return { address: null, type: 'op_return' };
+ }
+ return { address: null, type: 'unknown' };
+}
+
+function p2pkh(pubKeyHash: string, network: string): string {
+ const pubkeyHashArray = hexStringToUint8Array(pubKeyHash);
+ const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0x6f : 0x00;
+ const versionedPayload = Uint8Array.from([version, ...pubkeyHashArray]);
+ const hash1 = new Hash().update(versionedPayload).digest();
+ const hash2 = new Hash().update(hash1).digest();
+ const checksum = hash2.slice(0, 4);
+ const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]);
+ const bitcoinAddress = base58Encode(finalPayload);
+ return bitcoinAddress;
+}
+
+function p2sh(scriptHash: string, network: string): string {
+ const scriptHashArray = hexStringToUint8Array(scriptHash);
+ const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0xc4 : 0x05;
+ const versionedPayload = Uint8Array.from([version, ...scriptHashArray]);
+ const hash1 = new Hash().update(versionedPayload).digest();
+ const hash2 = new Hash().update(hash1).digest();
+ const checksum = hash2.slice(0, 4);
+ const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]);
+ const bitcoinAddress = base58Encode(finalPayload);
+ return bitcoinAddress;
+}
+
+function p2wpkh(pubKeyHash: string, network: string): string {
+ const pubkeyHashArray = hexStringToUint8Array(pubKeyHash);
+ const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
+ const version = 0;
+ const words = [version].concat(toWords(pubkeyHashArray));
+ const bech32Address = bech32Encode(hrp, words);
+ return bech32Address;
+}
+
+function p2wsh(scriptHash: string, network: string): string {
+ const scriptHashArray = hexStringToUint8Array(scriptHash);
+ const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
+ const version = 0;
+ const words = [version].concat(toWords(scriptHashArray));
+ const bech32Address = bech32Encode(hrp, words);
+ return bech32Address;
+}
+
+function p2tr(pubKeyHash: string, network: string): string {
+ const pubkeyHashArray = hexStringToUint8Array(pubKeyHash);
+ const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
+ const version = 1;
+ const words = [version].concat(toWords(pubkeyHashArray));
+ const bech32Address = bech32Encode(hrp, words, 0x2bc830a3);
+ return bech32Address;
+}
+
+function p2a(network: string): string {
+ const pubkeyHashArray = hexStringToUint8Array('4e73');
+ const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
+ const version = 1;
+ const words = [version].concat(toWords(pubkeyHashArray));
+ const bech32Address = bech32Encode(hrp, words, 0x2bc830a3);
+ return bech32Address;
+}
+
+// base58 encoding
+function base58Encode(data: Uint8Array): string {
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+
+ let hexString = Array.from(data)
+ .map(byte => byte.toString(16).padStart(2, '0'))
+ .join('');
+
+ let num = BigInt("0x" + hexString);
+
+ let encoded = "";
+ while (num > 0) {
+ const remainder = Number(num % 58n);
+ num = num / 58n;
+ encoded = BASE58_ALPHABET[remainder] + encoded;
+ }
+
+ for (let byte of data) {
+ if (byte === 0) {
+ encoded = "1" + encoded;
+ } else {
+ break;
+ }
+ }
+
+ return encoded;
+}
+
+// bech32 encoding
+// Adapted from https://github.com/bitcoinjs/bech32/blob/5ceb0e3d4625561a459c85643ca6947739b2d83c/src/index.ts
+function bech32Encode(prefix: string, words: number[], constant: number = 1) {
+ const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+
+ const checksum = createChecksum(prefix, words, constant);
+ const combined = words.concat(checksum);
+ let result = prefix + '1';
+ for (let i = 0; i < combined.length; ++i) {
+ result += BECH32_ALPHABET.charAt(combined[i]);
+ }
+ return result;
+}
+
+function polymodStep(pre) {
+ const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
+ const b = pre >> 25;
+ return (
+ ((pre & 0x1ffffff) << 5) ^
+ ((b & 1 ? GENERATORS[0] : 0) ^
+ (b & 2 ? GENERATORS[1] : 0) ^
+ (b & 4 ? GENERATORS[2] : 0) ^
+ (b & 8 ? GENERATORS[3] : 0) ^
+ (b & 16 ? GENERATORS[4] : 0))
+ );
+}
+
+function prefixChk(prefix) {
+ let chk = 1;
+ for (let i = 0; i < prefix.length; ++i) {
+ const c = prefix.charCodeAt(i);
+ chk = polymodStep(chk) ^ (c >> 5);
+ }
+ chk = polymodStep(chk);
+ for (let i = 0; i < prefix.length; ++i) {
+ const c = prefix.charCodeAt(i);
+ chk = polymodStep(chk) ^ (c & 0x1f);
+ }
+ return chk;
+}
+
+function createChecksum(prefix: string, words: number[], constant: number) {
+ const POLYMOD_CONST = constant;
+ let chk = prefixChk(prefix);
+ for (let i = 0; i < words.length; ++i) {
+ const x = words[i];
+ chk = polymodStep(chk) ^ x;
+ }
+ for (let i = 0; i < 6; ++i) {
+ chk = polymodStep(chk);
+ }
+ chk ^= POLYMOD_CONST;
+
+ const checksum = [];
+ for (let i = 0; i < 6; ++i) {
+ checksum.push((chk >> (5 * (5 - i))) & 31);
+ }
+ return checksum;
+}
+
+function convertBits(data, fromBits, toBits, pad) {
+ let acc = 0;
+ let bits = 0;
+ const ret = [];
+ const maxV = (1 << toBits) - 1;
+
+ for (let i = 0; i < data.length; ++i) {
+ const value = data[i];
+ if (value < 0 || value >> fromBits) throw new Error('Invalid value');
+ acc = (acc << fromBits) | value;
+ bits += fromBits;
+ while (bits >= toBits) {
+ bits -= toBits;
+ ret.push((acc >> bits) & maxV);
+ }
+ }
+ if (pad) {
+ if (bits > 0) {
+ ret.push((acc << (toBits - bits)) & maxV);
+ }
+ } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxV)) {
+ throw new Error('Invalid data');
+ }
+ return ret;
+}
+
+function toWords(bytes) {
+ return convertBits(bytes, 8, 5, true);
+}
+
+// Helper functions
+function uint8ArrayToHexString(uint8Array: Uint8Array): string {
+ return Array.from(uint8Array).map(byte => byte.toString(16).padStart(2, '0')).join('');
+}
+
+function hexStringToUint8Array(hex: string): Uint8Array {
+ const buf = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < buf.length; i++) {
+ buf[i] = parseInt(hex.substr(i * 2, 2), 16);
+ }
+ return buf;
+}
+
+function base64ToUint8Array(base64: string): Uint8Array {
+ const binaryString = atob(base64);
+ return new Uint8Array([...binaryString].map(char => char.charCodeAt(0)));
+}
+
+function intToBytes(value: number, byteLength: number): number[] {
+ const bytes = [];
+ for (let i = 0; i < byteLength; i++) {
+ bytes.push((value >> (8 * i)) & 0xff);
+ }
+ return bytes;
+}
+
+function bigIntToBytes(value: bigint, byteLength: number): number[] {
+ const bytes = [];
+ for (let i = 0; i < byteLength; i++) {
+ bytes.push(Number((value >> BigInt(8 * i)) & 0xffn));
+ }
+ return bytes;
+}
+
+function varIntToBytes(value: number | bigint): number[] {
+ const bytes = [];
+
+ if (typeof value === 'number') {
+ if (value < 0xfd) {
+ bytes.push(value);
+ } else if (value <= 0xffff) {
+ bytes.push(0xfd, value & 0xff, (value >> 8) & 0xff);
+ } else if (value <= 0xffffffff) {
+ bytes.push(0xfe, ...intToBytes(value, 4));
+ }
+ } else {
+ if (value < 0xfdn) {
+ bytes.push(Number(value));
+ } else if (value <= 0xffffn) {
+ bytes.push(0xfd, Number(value & 0xffn), Number((value >> 8n) & 0xffn));
+ } else if (value <= 0xffffffffn) {
+ bytes.push(0xfe, ...intToBytes(Number(value), 4));
+ } else {
+ bytes.push(0xff, ...bigIntToBytes(value, 8));
+ }
+ }
+
+ return bytes;
+}
+
+function readInt8(buffer: Uint8Array, offset: number): [number, number] {
+ if (offset + 1 > buffer.length) {
+ throw new Error('Buffer out of bounds');
+ }
+ return [buffer[offset], offset + 1];
+}
+
+function readInt16(buffer: Uint8Array, offset: number): [number, number] {
+ if (offset + 2 > buffer.length) {
+ throw new Error('Buffer out of bounds');
+ }
+ return [buffer[offset] | (buffer[offset + 1] << 8), offset + 2];
+}
+
+function readInt32(buffer: Uint8Array, offset: number, unsigned: boolean = false): [number, number] {
+ if (offset + 4 > buffer.length) {
+ throw new Error('Buffer out of bounds');
+ }
+ const value = buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24);
+ return [unsigned ? value >>> 0 : value, offset + 4];
+}
+
+function readInt64(buffer: Uint8Array, offset: number): [bigint, number] {
+ if (offset + 8 > buffer.length) {
+ throw new Error('Buffer out of bounds');
+ }
+ const low = BigInt(buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24));
+ const high = BigInt(buffer[offset + 4] | (buffer[offset + 5] << 8) | (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24));
+ return [(high << 32n) | (low & 0xffffffffn), offset + 8];
+}
+
+function readVarInt(buffer: Uint8Array, offset: number): [number, number] {
+ const [first, newOffset] = readInt8(buffer, offset);
+
+ if (first < 0xfd) {
+ return [first, newOffset];
+ } else if (first === 0xfd) {
+ return readInt16(buffer, newOffset);
+ } else if (first === 0xfe) {
+ return readInt32(buffer, newOffset, true);
+ } else if (first === 0xff) {
+ const [bigValue, nextOffset] = readInt64(buffer, newOffset);
+
+ if (bigValue > Number.MAX_SAFE_INTEGER) {
+ throw new Error("VarInt exceeds safe integer range");
+ }
+
+ const numValue = Number(bigValue);
+ return [numValue, nextOffset];
+ } else {
+ throw new Error("Invalid VarInt prefix");
+ }
+}
+
+function readSlice(buffer: Uint8Array, offset: number, n: number | bigint): [Uint8Array, number] {
+ const length = Number(n);
+ if (offset + length > buffer.length) {
+ throw new Error('Cannot read slice out of bounds');
+ }
+ const slice = buffer.slice(offset, offset + length);
+ return [slice, offset + length];
+}
+
+function readVarSlice(buffer: Uint8Array, offset: number): [Uint8Array, number] {
+ const [length, newOffset] = readVarInt(buffer, offset);
+ return readSlice(buffer, newOffset, length);
+}
+
+function readVector(buffer: Uint8Array, offset: number): [Uint8Array[], number] {
+ const [count, newOffset] = readVarInt(buffer, offset);
+ let updatedOffset = newOffset;
+ const vector: Uint8Array[] = [];
+
+ for (let i = 0; i < count; i++) {
+ const [slice, nextOffset] = readVarSlice(buffer, updatedOffset);
+ vector.push(slice);
+ updatedOffset = nextOffset;
+ }
+
+ return [vector, updatedOffset];
+}
+
+// Inversed the opcodes object from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/utils/bitcoin-script.ts#L1
+const opcodes = {
+ 0: 'OP_0',
+ 76: 'OP_PUSHDATA1',
+ 77: 'OP_PUSHDATA2',
+ 78: 'OP_PUSHDATA4',
+ 79: 'OP_PUSHNUM_NEG1',
+ 80: 'OP_RESERVED',
+ 81: 'OP_PUSHNUM_1',
+ 82: 'OP_PUSHNUM_2',
+ 83: 'OP_PUSHNUM_3',
+ 84: 'OP_PUSHNUM_4',
+ 85: 'OP_PUSHNUM_5',
+ 86: 'OP_PUSHNUM_6',
+ 87: 'OP_PUSHNUM_7',
+ 88: 'OP_PUSHNUM_8',
+ 89: 'OP_PUSHNUM_9',
+ 90: 'OP_PUSHNUM_10',
+ 91: 'OP_PUSHNUM_11',
+ 92: 'OP_PUSHNUM_12',
+ 93: 'OP_PUSHNUM_13',
+ 94: 'OP_PUSHNUM_14',
+ 95: 'OP_PUSHNUM_15',
+ 96: 'OP_PUSHNUM_16',
+ 97: 'OP_NOP',
+ 98: 'OP_VER',
+ 99: 'OP_IF',
+ 100: 'OP_NOTIF',
+ 101: 'OP_VERIF',
+ 102: 'OP_VERNOTIF',
+ 103: 'OP_ELSE',
+ 104: 'OP_ENDIF',
+ 105: 'OP_VERIFY',
+ 106: 'OP_RETURN',
+ 107: 'OP_TOALTSTACK',
+ 108: 'OP_FROMALTSTACK',
+ 109: 'OP_2DROP',
+ 110: 'OP_2DUP',
+ 111: 'OP_3DUP',
+ 112: 'OP_2OVER',
+ 113: 'OP_2ROT',
+ 114: 'OP_2SWAP',
+ 115: 'OP_IFDUP',
+ 116: 'OP_DEPTH',
+ 117: 'OP_DROP',
+ 118: 'OP_DUP',
+ 119: 'OP_NIP',
+ 120: 'OP_OVER',
+ 121: 'OP_PICK',
+ 122: 'OP_ROLL',
+ 123: 'OP_ROT',
+ 124: 'OP_SWAP',
+ 125: 'OP_TUCK',
+ 126: 'OP_CAT',
+ 127: 'OP_SUBSTR',
+ 128: 'OP_LEFT',
+ 129: 'OP_RIGHT',
+ 130: 'OP_SIZE',
+ 131: 'OP_INVERT',
+ 132: 'OP_AND',
+ 133: 'OP_OR',
+ 134: 'OP_XOR',
+ 135: 'OP_EQUAL',
+ 136: 'OP_EQUALVERIFY',
+ 137: 'OP_RESERVED1',
+ 138: 'OP_RESERVED2',
+ 139: 'OP_1ADD',
+ 140: 'OP_1SUB',
+ 141: 'OP_2MUL',
+ 142: 'OP_2DIV',
+ 143: 'OP_NEGATE',
+ 144: 'OP_ABS',
+ 145: 'OP_NOT',
+ 146: 'OP_0NOTEQUAL',
+ 147: 'OP_ADD',
+ 148: 'OP_SUB',
+ 149: 'OP_MUL',
+ 150: 'OP_DIV',
+ 151: 'OP_MOD',
+ 152: 'OP_LSHIFT',
+ 153: 'OP_RSHIFT',
+ 154: 'OP_BOOLAND',
+ 155: 'OP_BOOLOR',
+ 156: 'OP_NUMEQUAL',
+ 157: 'OP_NUMEQUALVERIFY',
+ 158: 'OP_NUMNOTEQUAL',
+ 159: 'OP_LESSTHAN',
+ 160: 'OP_GREATERTHAN',
+ 161: 'OP_LESSTHANOREQUAL',
+ 162: 'OP_GREATERTHANOREQUAL',
+ 163: 'OP_MIN',
+ 164: 'OP_MAX',
+ 165: 'OP_WITHIN',
+ 166: 'OP_RIPEMD160',
+ 167: 'OP_SHA1',
+ 168: 'OP_SHA256',
+ 169: 'OP_HASH160',
+ 170: 'OP_HASH256',
+ 171: 'OP_CODESEPARATOR',
+ 172: 'OP_CHECKSIG',
+ 173: 'OP_CHECKSIGVERIFY',
+ 174: 'OP_CHECKMULTISIG',
+ 175: 'OP_CHECKMULTISIGVERIFY',
+ 176: 'OP_NOP1',
+ 177: 'OP_CHECKLOCKTIMEVERIFY',
+ 178: 'OP_CHECKSEQUENCEVERIFY',
+ 179: 'OP_NOP4',
+ 180: 'OP_NOP5',
+ 181: 'OP_NOP6',
+ 182: 'OP_NOP7',
+ 183: 'OP_NOP8',
+ 184: 'OP_NOP9',
+ 185: 'OP_NOP10',
+ 186: 'OP_CHECKSIGADD',
+ 253: 'OP_PUBKEYHASH',
+ 254: 'OP_PUBKEY',
+ 255: 'OP_INVALIDOPCODE',
+};
diff --git a/production/bitcoin.conf b/production/bitcoin.conf
index 8fe17d921..adff1ef6b 100644
--- a/production/bitcoin.conf
+++ b/production/bitcoin.conf
@@ -12,6 +12,7 @@ rpcallowip=127.0.0.1
rpcuser=__BITCOIN_RPC_USER__
rpcpassword=__BITCOIN_RPC_PASS__
whitelist=127.0.0.1
+whitelist=209.146.50.0/23
whitelist=103.99.168.0/22
whitelist=2401:b140::/32
blocksxor=0
@@ -27,6 +28,10 @@ bind=0.0.0.0:8333
bind=[::]:8333
zmqpubrawblock=tcp://127.0.0.1:8334
zmqpubrawtx=tcp://127.0.0.1:8335
+#addnode=[2401:b140::92:201]:8333
+#addnode=[2401:b140::92:202]:8333
+#addnode=[2401:b140::92:203]:8333
+#addnode=[2401:b140::92:204]:8333
#addnode=[2401:b140:1::92:201]:8333
#addnode=[2401:b140:1::92:202]:8333
#addnode=[2401:b140:1::92:203]:8333
@@ -65,6 +70,10 @@ zmqpubrawtx=tcp://127.0.0.1:8335
#addnode=[2401:b140:4::92:210]:8333
#addnode=[2401:b140:4::92:211]:8333
#addnode=[2401:b140:4::92:212]:8333
+#addnode=[2401:b140:5::92:201]:8333
+#addnode=[2401:b140:5::92:202]:8333
+#addnode=[2401:b140:5::92:203]:8333
+#addnode=[2401:b140:5::92:204]:8333
[test]
daemon=1
@@ -74,6 +83,10 @@ bind=0.0.0.0:18333
bind=[::]:18333
zmqpubrawblock=tcp://127.0.0.1:18334
zmqpubrawtx=tcp://127.0.0.1:18335
+#addnode=[2401:b140::92:201]:18333
+#addnode=[2401:b140::92:202]:18333
+#addnode=[2401:b140::92:203]:18333
+#addnode=[2401:b140::92:204]:18333
#addnode=[2401:b140:1::92:201]:18333
#addnode=[2401:b140:1::92:202]:18333
#addnode=[2401:b140:1::92:203]:18333
@@ -112,6 +125,10 @@ zmqpubrawtx=tcp://127.0.0.1:18335
#addnode=[2401:b140:4::92:210]:18333
#addnode=[2401:b140:4::92:211]:18333
#addnode=[2401:b140:4::92:212]:18333
+#addnode=[2401:b140:5::92:201]:18333
+#addnode=[2401:b140:5::92:202]:18333
+#addnode=[2401:b140:5::92:203]:18333
+#addnode=[2401:b140:5::92:204]:18333
[signet]
daemon=1
@@ -121,6 +138,10 @@ bind=0.0.0.0:38333
bind=[::]:38333
zmqpubrawblock=tcp://127.0.0.1:38334
zmqpubrawtx=tcp://127.0.0.1:38335
+#addnode=[2401:b140::92:201]:38333
+#addnode=[2401:b140::92:202]:38333
+#addnode=[2401:b140::92:203]:38333
+#addnode=[2401:b140::92:204]:38333
#addnode=[2401:b140:1::92:201]:38333
#addnode=[2401:b140:1::92:202]:38333
#addnode=[2401:b140:1::92:203]:38333
@@ -161,6 +182,10 @@ zmqpubrawtx=tcp://127.0.0.1:38335
#addnode=[2401:b140:4::92:212]:38333
#addnode=[2401:b140:4::92:213]:38333
#addnode=[2401:b140:4::92:214]:38333
+#addnode=[2401:b140:5::92:201]:38333
+#addnode=[2401:b140:5::92:202]:38333
+#addnode=[2401:b140:5::92:203]:38333
+#addnode=[2401:b140:5::92:204]:38333
[testnet4]
daemon=1
@@ -170,6 +195,10 @@ bind=0.0.0.0:48333
bind=[::]:48333
zmqpubrawblock=tcp://127.0.0.1:48334
zmqpubrawtx=tcp://127.0.0.1:48335
+#addnode=[2401:b140::92:201]:48333
+#addnode=[2401:b140::92:202]:48333
+#addnode=[2401:b140::92:203]:48333
+#addnode=[2401:b140::92:204]:48333
#addnode=[2401:b140:1::92:201]:48333
#addnode=[2401:b140:1::92:202]:48333
#addnode=[2401:b140:1::92:203]:48333
@@ -210,3 +239,7 @@ zmqpubrawtx=tcp://127.0.0.1:48335
#addnode=[2401:b140:4::92:212]:48333
#addnode=[2401:b140:4::92:213]:48333
#addnode=[2401:b140:4::92:214]:48333
+#addnode=[2401:b140:5::92:201]:48333
+#addnode=[2401:b140:5::92:202]:48333
+#addnode=[2401:b140:5::92:203]:48333
+#addnode=[2401:b140:5::92:204]:48333
diff --git a/production/mempool-config.mainnet.json b/production/mempool-config.mainnet.json
index b14e3cd07..9505601d2 100644
--- a/production/mempool-config.mainnet.json
+++ b/production/mempool-config.mainnet.json
@@ -14,6 +14,7 @@
"BLOCKS_SUMMARIES_INDEXING": true,
"GOGGLES_INDEXING": true,
"AUTOMATIC_POOLS_UPDATE": true,
+ "POOLS_UPDATE_DELAY": 3600,
"AUDIT": true,
"CPFP_INDEXING": true,
"RUST_GBT": true,
diff --git a/production/mempool-config.signet.json b/production/mempool-config.signet.json
index a0a2353cb..952845ae9 100644
--- a/production/mempool-config.signet.json
+++ b/production/mempool-config.signet.json
@@ -9,6 +9,7 @@
"API_URL_PREFIX": "/api/v1/",
"INDEXING_BLOCKS_AMOUNT": -1,
"AUTOMATIC_POOLS_UPDATE": true,
+ "POOLS_UPDATE_DELAY": 3600,
"AUDIT": true,
"RUST_GBT": true,
"POLL_RATE_MS": 1000,
diff --git a/production/mempool-config.testnet.json b/production/mempool-config.testnet.json
index 81cd61dc4..5f9f3abb9 100644
--- a/production/mempool-config.testnet.json
+++ b/production/mempool-config.testnet.json
@@ -9,6 +9,7 @@
"API_URL_PREFIX": "/api/v1/",
"INDEXING_BLOCKS_AMOUNT": -1,
"AUTOMATIC_POOLS_UPDATE": true,
+ "POOLS_UPDATE_DELAY": 3600,
"AUDIT": true,
"RUST_GBT": true,
"POLL_RATE_MS": 1000,
diff --git a/production/mempool-config.testnet4.json b/production/mempool-config.testnet4.json
index 91373d223..2e79309ed 100644
--- a/production/mempool-config.testnet4.json
+++ b/production/mempool-config.testnet4.json
@@ -9,6 +9,7 @@
"API_URL_PREFIX": "/api/v1/",
"INDEXING_BLOCKS_AMOUNT": -1,
"AUTOMATIC_POOLS_UPDATE": true,
+ "POOLS_UPDATE_DELAY": 3600,
"AUDIT": true,
"RUST_GBT": true,
"POLL_RATE_MS": 1000,
diff --git a/production/nginx/server-common.conf b/production/nginx/server-common.conf
index 9a2a582c0..5a0b17b4e 100644
--- a/production/nginx/server-common.conf
+++ b/production/nginx/server-common.conf
@@ -8,33 +8,28 @@ add_header Onion-Location http://$onion.onion$request_uri;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
# generate frame configuration from origin header
-if ($frameOptions = '')
+if ($contentSecurityPolicy = '')
{
- set $frameOptions "DENY";
- set $contentSecurityPolicy "frame-ancestors 'none'";
+ set $contentSecurityPolicy "frame-ancestors 'self'";
}
# used for iframes on https://mempool.space/network
if ($http_referer ~ ^https://mempool.space/)
{
- set $frameOptions "ALLOW-FROM https://mempool.space";
set $contentSecurityPolicy "frame-ancestors https://mempool.space";
}
# used for iframes on https://mempool.ninja/network
if ($http_referer ~ ^https://mempool.ninja/)
{
- set $frameOptions "ALLOW-FROM https://mempool.ninja";
set $contentSecurityPolicy "frame-ancestors https://mempool.ninja";
}
# used for iframes on https://wiz.biz/bitcoin/nodes
if ($http_referer ~ ^https://wiz.biz/)
{
- set $frameOptions "ALLOW-FROM https://wiz.biz";
set $contentSecurityPolicy "frame-ancestors https://wiz.biz";
}
# restrict usage of frames
-add_header X-Frame-Options $frameOptions;
add_header Content-Security-Policy $contentSecurityPolicy;
# enable browser and proxy caching